From abacb72b3018a1e0cc4635d6cf3eb1a02b8b83ba Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 7 Apr 2019 15:34:33 +0000 Subject: [PATCH 001/422] Add gitignore --- .gitignore | 193 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8cab59f --- /dev/null +++ b/.gitignore @@ -0,0 +1,193 @@ + +# Created by https://www.gitignore.io/api/django,python +# Edit at https://www.gitignore.io/?templates=django,python + +### Django ### +*.log +*.pot +*.pyc +__pycache__/ +local_settings.py +db.sqlite3 +media + +# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ +# in your Git repository. Update and uncomment the following line accordingly. +# /staticfiles/ + +### Django.Python Stack ### +# Byte-compiled / optimized / DLL files +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo + +# Django stuff: + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don’t work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +### Python ### +# Byte-compiled / optimized / DLL files + +# C extensions + +# Distribution / packaging + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. + +# Installer logs + +# Unit test / coverage reports + +# Translations + +# Django stuff: + +# Flask stuff: + +# Scrapy stuff: + +# Sphinx documentation + +# PyBuilder + +# Jupyter Notebook + +# IPython + +# pyenv + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don’t work, or not +# install all needed dependencies. + +# celery beat schedule file + +# SageMath parsed files + +# Environments + +# Spyder project settings + +# Rope project settings + +# mkdocs documentation + +# mypy + +# Pyre type checker + +# End of https://www.gitignore.io/api/django,python From c508cca08016076a68f6f3f17b6773858d7fd296 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sat, 22 Jun 2019 09:29:56 +0000 Subject: [PATCH 002/422] Merge first implementation --- .gitignore | 1 + .isort.cfg | 7 + requirements/base.txt | 12 + requirements/dev.txt | 4 + src/manage.py | 21 + src/newsreader/__init__.py | 0 src/newsreader/conf/__init__.py | 0 src/newsreader/conf/base.py | 111 +++ src/newsreader/conf/dev.py | 12 + src/newsreader/core/__init__.py | 0 src/newsreader/core/admin.py | 3 + src/newsreader/core/apps.py | 5 + src/newsreader/core/migrations/__init__.py | 0 src/newsreader/core/models.py | 13 + src/newsreader/core/tests.py | 3 + src/newsreader/core/views.py | 3 + src/newsreader/news/__init__.py | 0 src/newsreader/news/collection/__init__.py | 0 src/newsreader/news/collection/admin.py | 23 + src/newsreader/news/collection/apps.py | 5 + src/newsreader/news/collection/base.py | 81 ++ src/newsreader/news/collection/exceptions.py | 28 + src/newsreader/news/collection/feed.py | 211 +++++ .../collection/management/commands/collect.py | 14 + .../collection/migrations/0001_initial.py | 31 + .../migrations/0002_auto_20190410_2028.py | 18 + .../0003_collectionrule_category.py | 28 + .../0004_collectionrule_timezone.py | 517 +++++++++++ .../migrations/0005_auto_20190521_1941.py | 24 + .../migrations/0006_collectionrule_error.py | 18 + .../news/collection/migrations/__init__.py | 0 src/newsreader/news/collection/models.py | 35 + .../news/collection/response_handler.py | 30 + .../news/collection/tests/__init__.py | 1 + .../news/collection/tests/factories.py | 12 + .../news/collection/tests/feed/__init__.py | 5 + .../collection/tests/feed/builder/__init__.py | 1 + .../tests/feed/builder/mock_html.py | 10 + .../collection/tests/feed/builder/mocks.py | 878 ++++++++++++++++++ .../collection/tests/feed/builder/tests.py | 304 ++++++ .../collection/tests/feed/client/__init__.py | 1 + .../collection/tests/feed/client/mocks.py | 61 ++ .../collection/tests/feed/client/tests.py | 90 ++ .../tests/feed/collector/__init__.py | 1 + .../collection/tests/feed/collector/mocks.py | 430 +++++++++ .../collection/tests/feed/collector/tests.py | 251 +++++ .../tests/feed/duplicate_handler/__init__.py | 1 + .../tests/feed/duplicate_handler/tests.py | 63 ++ .../collection/tests/feed/stream/__init__.py | 1 + .../collection/tests/feed/stream/mocks.py | 61 ++ .../collection/tests/feed/stream/tests.py | 109 +++ src/newsreader/news/collection/utils.py | 13 + src/newsreader/news/collection/views.py | 3 + src/newsreader/news/posts/__init__.py | 0 src/newsreader/news/posts/admin.py | 39 + src/newsreader/news/posts/apps.py | 5 + .../news/posts/migrations/0001_initial.py | 78 ++ .../migrations/0002_auto_20190520_2206.py | 20 + .../migrations/0003_auto_20190520_2031.py | 18 + .../migrations/0004_auto_20190521_1941.py | 22 + .../migrations/0005_auto_20190608_1054.py | 23 + .../migrations/0006_auto_20190608_1520.py | 33 + .../news/posts/migrations/__init__.py | 0 src/newsreader/news/posts/models.py | 34 + src/newsreader/news/posts/tests/__init__.py | 0 src/newsreader/news/posts/tests/factories.py | 28 + src/newsreader/news/posts/views.py | 3 + src/newsreader/urls.py | 6 + src/newsreader/utils/formatter.sh | 10 + src/newsreader/utils/pre-commit | 13 + src/newsreader/wsgi.py | 16 + 71 files changed, 3902 insertions(+) create mode 100644 .isort.cfg create mode 100644 requirements/base.txt create mode 100644 requirements/dev.txt create mode 100755 src/manage.py create mode 100644 src/newsreader/__init__.py create mode 100644 src/newsreader/conf/__init__.py create mode 100644 src/newsreader/conf/base.py create mode 100644 src/newsreader/conf/dev.py create mode 100644 src/newsreader/core/__init__.py create mode 100644 src/newsreader/core/admin.py create mode 100644 src/newsreader/core/apps.py create mode 100644 src/newsreader/core/migrations/__init__.py create mode 100644 src/newsreader/core/models.py create mode 100644 src/newsreader/core/tests.py create mode 100644 src/newsreader/core/views.py create mode 100644 src/newsreader/news/__init__.py create mode 100644 src/newsreader/news/collection/__init__.py create mode 100644 src/newsreader/news/collection/admin.py create mode 100644 src/newsreader/news/collection/apps.py create mode 100644 src/newsreader/news/collection/base.py create mode 100644 src/newsreader/news/collection/exceptions.py create mode 100644 src/newsreader/news/collection/feed.py create mode 100644 src/newsreader/news/collection/management/commands/collect.py create mode 100644 src/newsreader/news/collection/migrations/0001_initial.py create mode 100644 src/newsreader/news/collection/migrations/0002_auto_20190410_2028.py create mode 100644 src/newsreader/news/collection/migrations/0003_collectionrule_category.py create mode 100644 src/newsreader/news/collection/migrations/0004_collectionrule_timezone.py create mode 100644 src/newsreader/news/collection/migrations/0005_auto_20190521_1941.py create mode 100644 src/newsreader/news/collection/migrations/0006_collectionrule_error.py create mode 100644 src/newsreader/news/collection/migrations/__init__.py create mode 100644 src/newsreader/news/collection/models.py create mode 100644 src/newsreader/news/collection/response_handler.py create mode 100644 src/newsreader/news/collection/tests/__init__.py create mode 100644 src/newsreader/news/collection/tests/factories.py create mode 100644 src/newsreader/news/collection/tests/feed/__init__.py create mode 100644 src/newsreader/news/collection/tests/feed/builder/__init__.py create mode 100644 src/newsreader/news/collection/tests/feed/builder/mock_html.py create mode 100644 src/newsreader/news/collection/tests/feed/builder/mocks.py create mode 100644 src/newsreader/news/collection/tests/feed/builder/tests.py create mode 100644 src/newsreader/news/collection/tests/feed/client/__init__.py create mode 100644 src/newsreader/news/collection/tests/feed/client/mocks.py create mode 100644 src/newsreader/news/collection/tests/feed/client/tests.py create mode 100644 src/newsreader/news/collection/tests/feed/collector/__init__.py create mode 100644 src/newsreader/news/collection/tests/feed/collector/mocks.py create mode 100644 src/newsreader/news/collection/tests/feed/collector/tests.py create mode 100644 src/newsreader/news/collection/tests/feed/duplicate_handler/__init__.py create mode 100644 src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py create mode 100644 src/newsreader/news/collection/tests/feed/stream/__init__.py create mode 100644 src/newsreader/news/collection/tests/feed/stream/mocks.py create mode 100644 src/newsreader/news/collection/tests/feed/stream/tests.py create mode 100644 src/newsreader/news/collection/utils.py create mode 100644 src/newsreader/news/collection/views.py create mode 100644 src/newsreader/news/posts/__init__.py create mode 100644 src/newsreader/news/posts/admin.py create mode 100644 src/newsreader/news/posts/apps.py create mode 100644 src/newsreader/news/posts/migrations/0001_initial.py create mode 100644 src/newsreader/news/posts/migrations/0002_auto_20190520_2206.py create mode 100644 src/newsreader/news/posts/migrations/0003_auto_20190520_2031.py create mode 100644 src/newsreader/news/posts/migrations/0004_auto_20190521_1941.py create mode 100644 src/newsreader/news/posts/migrations/0005_auto_20190608_1054.py create mode 100644 src/newsreader/news/posts/migrations/0006_auto_20190608_1520.py create mode 100644 src/newsreader/news/posts/migrations/__init__.py create mode 100644 src/newsreader/news/posts/models.py create mode 100644 src/newsreader/news/posts/tests/__init__.py create mode 100644 src/newsreader/news/posts/tests/factories.py create mode 100644 src/newsreader/news/posts/views.py create mode 100644 src/newsreader/urls.py create mode 100644 src/newsreader/utils/formatter.sh create mode 100644 src/newsreader/utils/pre-commit create mode 100644 src/newsreader/wsgi.py diff --git a/.gitignore b/.gitignore index 8cab59f..8d9e86a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ *.pyc __pycache__/ local_settings.py +local.py db.sqlite3 media diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..e453b8d --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,7 @@ +[settings] +include_trailing_comma = true +line_length = 80 +multi_line_output = 3 +skip = env/ +forced_separate=django, newsreader +lines_between_types=1 diff --git a/requirements/base.txt b/requirements/base.txt new file mode 100644 index 0000000..0f82f61 --- /dev/null +++ b/requirements/base.txt @@ -0,0 +1,12 @@ +certifi==2019.3.9 +chardet==3.0.4 +Django==2.2 +feedparser==5.2.1 +idna==2.8 +pkg-resources==0.0.0 +pytz==2018.9 +requests==2.21.0 +sqlparse==0.3.0 +urllib3==1.24.1 +psycopg2-binary==2.8.1 +Pillow==6.0.0 diff --git a/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 0000000..0a685e3 --- /dev/null +++ b/requirements/dev.txt @@ -0,0 +1,4 @@ +-r base.txt + +factory-boy==2.12.0 +freezegun==0.3.12 diff --git a/src/manage.py b/src/manage.py new file mode 100755 index 0000000..a30fa10 --- /dev/null +++ b/src/manage.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'newsreader.conf.base') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/src/newsreader/__init__.py b/src/newsreader/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/conf/__init__.py b/src/newsreader/conf/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py new file mode 100644 index 0000000..4b9f9df --- /dev/null +++ b/src/newsreader/conf/base.py @@ -0,0 +1,111 @@ +""" +Django settings for newsreader project. + +Generated by "django-admin startproject" using Django 2.2. + +For more information on this file, see +https://docs.djangoproject.com/en/2.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/2.2/ref/settings/ +""" + +import os + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "^!7a2jq5j!exc-55vf$anx9^6ff6=u_ub5=5p1(1x47fix)syh" + +# SECURITY WARNING: don"t run with debug turned on in production! +DEBUG = False + +ALLOWED_HOSTS = ["127.0.0.1"] + +# Application definition +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + # app modules + "newsreader.news.collection", + "newsreader.news.posts", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "newsreader.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "newsreader.wsgi.application" + +# Database +# https://docs.djangoproject.com/en/2.2/ref/settings/#databases +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql_psycopg2", + "NAME": "newsreader", + "USER": "newsreader", + } +} + +# Password validation +# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": + "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +# Internationalization +# https://docs.djangoproject.com/en/2.2/topics/i18n/ +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" +USE_I18N = True +USE_L10N = True +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/2.2/howto/static-files/ +STATIC_URL = "/static/" diff --git a/src/newsreader/conf/dev.py b/src/newsreader/conf/dev.py new file mode 100644 index 0000000..8b5de69 --- /dev/null +++ b/src/newsreader/conf/dev.py @@ -0,0 +1,12 @@ +from .base import * + +# Development settings + +DEBUG = True + +EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + +try: + from .local import * +except ImportError: + pass diff --git a/src/newsreader/core/__init__.py b/src/newsreader/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/core/admin.py b/src/newsreader/core/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/src/newsreader/core/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/src/newsreader/core/apps.py b/src/newsreader/core/apps.py new file mode 100644 index 0000000..26f78a8 --- /dev/null +++ b/src/newsreader/core/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + name = 'core' diff --git a/src/newsreader/core/migrations/__init__.py b/src/newsreader/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/core/models.py b/src/newsreader/core/models.py new file mode 100644 index 0000000..4bd2e28 --- /dev/null +++ b/src/newsreader/core/models.py @@ -0,0 +1,13 @@ +from django.db import models + + +class TimeStampedModel(models.Model): + """ + An abstract base class model that provides self- + updating ``created`` and ``modified`` fields. + """ + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True diff --git a/src/newsreader/core/tests.py b/src/newsreader/core/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/src/newsreader/core/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/src/newsreader/core/views.py b/src/newsreader/core/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/src/newsreader/core/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/src/newsreader/news/__init__.py b/src/newsreader/news/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/__init__.py b/src/newsreader/news/collection/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/admin.py b/src/newsreader/news/collection/admin.py new file mode 100644 index 0000000..972b020 --- /dev/null +++ b/src/newsreader/news/collection/admin.py @@ -0,0 +1,23 @@ +from django.contrib import admin + +from newsreader.news.collection.models import CollectionRule + + +class CollectionRuleAdmin(admin.ModelAdmin): + fields = ( + "url", + "name", + "timezone", + "category", + ) + + list_display = ( + "name", + "category", + "url", + "last_suceeded", + "succeeded", + ) + + +admin.site.register(CollectionRule, CollectionRuleAdmin) diff --git a/src/newsreader/news/collection/apps.py b/src/newsreader/news/collection/apps.py new file mode 100644 index 0000000..1454371 --- /dev/null +++ b/src/newsreader/news/collection/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CollectionConfig(AppConfig): + name = 'collection' diff --git a/src/newsreader/news/collection/base.py b/src/newsreader/news/collection/base.py new file mode 100644 index 0000000..58cd9c4 --- /dev/null +++ b/src/newsreader/news/collection/base.py @@ -0,0 +1,81 @@ +import requests + +from django.utils import timezone + +from newsreader.news.collection.models import CollectionRule + + +class Builder: + instances = [] + + def __init__(self, stream): + self.stream = stream + + def __enter__(self): + self.create_posts(self.stream) + return self + + def __exit__(self, *args, **kwargs): + pass + + def create_posts(self, stream): + pass + + def save(self): + pass + + class Meta: + abstract = True + + +class Collector: + client = None + builder = None + + def __init__(self, client=None, builder=None): + self.client = client if client else self.client + self.builder = builder if builder else self.builder + + def collect(self, rules=None): + with self.client(rules=rules) as client: + for data, stream in client: + with self.builder((data, stream)) as builder: + builder.save() + + class Meta: + abstract = True + + +class Stream: + def __init__(self, rule): + self.rule = rule + + def read(self): + url = self.rule.url + response = requests.get(url) + return (self.parse(response.content), self) + + def parse(self, payload): + raise NotImplementedError + + class Meta: + abstract = True + + +class Client: + stream = Stream + + def __init__(self, rules=None): + self.rules = rules if rules else CollectionRule.objects.all() + + def __enter__(self): + for rule in self.rules: + stream = self.stream(rule) + + yield stream.read() + + def __exit__(self, *args, **kwargs): + pass + + class Meta: + abstract = True diff --git a/src/newsreader/news/collection/exceptions.py b/src/newsreader/news/collection/exceptions.py new file mode 100644 index 0000000..8e12da1 --- /dev/null +++ b/src/newsreader/news/collection/exceptions.py @@ -0,0 +1,28 @@ +class StreamException(Exception): + message = "Stream exception" + + def __init__(self, message=None): + self.message = message if message else self.message + + def __str__(self): + return self.message + + +class StreamNotFoundException(StreamException): + message = "Stream not found" + + +class StreamDeniedException(StreamException): + message = "Stream does not have sufficient permissions" + + +class StreamTimeOutException(StreamException): + message = "Stream timed out" + + +class StreamForbiddenException(StreamException): + message = "Stream forbidden" + + +class StreamParseException(StreamException): + message = "Stream could not be parsed" diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py new file mode 100644 index 0000000..fe4e2cf --- /dev/null +++ b/src/newsreader/news/collection/feed.py @@ -0,0 +1,211 @@ +from concurrent.futures import ThreadPoolExecutor, as_completed + +import bleach +import pytz +import requests + +from feedparser import parse + +from django.utils import timezone + +from newsreader.news.collection.base import Builder, Client, Collector, Stream +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamException, + StreamNotFoundException, + StreamParseException, + StreamTimeOutException, +) +from newsreader.news.collection.response_handler import ResponseHandler +from newsreader.news.collection.utils import build_publication_date +from newsreader.news.posts.models import Post + + +class FeedBuilder(Builder): + instances = [] + + def __enter__(self): + _, stream = self.stream + self.instances = [] + self.existing_posts = { + post.remote_identifier: post + for post in Post.objects.filter(rule=stream.rule) + } + + return super().__enter__() + + def create_posts(self, stream): + data, stream = stream + entries = [] + + with FeedDuplicateHandler(stream.rule) as duplicate_handler: + try: + entries = data["entries"] + except KeyError: + pass + + instances = self.build(entries, stream.rule) + posts = duplicate_handler.check(instances) + + self.instances = [post for post in posts] + + def build(self, entries, rule): + field_mapping = { + "id": "remote_identifier", + "title": "title", + "summary": "body", + "link": "url", + "published_parsed": "publication_date", + "author": "author" + } + + tz = pytz.timezone(rule.timezone) + + for entry in entries: + data = { + "rule_id": rule.pk, + "category": rule.category + } + + for field, value in field_mapping.items(): + if field in entry: + if field == "published_parsed": + created, aware_datetime = build_publication_date( + entry[field], tz + ) + data[value] = aware_datetime if created else None + elif field == "summary": + summary = self.sanitize_summary(entry[field]) + data[value] = summary + else: + data[value] = entry[field] + + yield Post(**data) + + def sanitize_summary(self, summary): + attrs = {"a": ["href", "rel"], "img": ["alt", "src"],} + tags = ["a", "img", "p"] + + return bleach.clean(summary, tags=tags, attributes=attrs) if summary else None + + def save(self): + for post in self.instances: + post.save() + + +class FeedStream(Stream): + def read(self): + url = self.rule.url + response = requests.get(url) + + with ResponseHandler(response) as response_handler: + response_handler.handle_response() + + return (self.parse(response.content), self) + + def parse(self, payload): + try: + return parse(payload) + except TypeError as e: + raise StreamParseException("Could not parse feed") from e + + +class FeedClient(Client): + stream = FeedStream + + def __enter__(self): + streams = [self.stream(rule) for rule in self.rules] + + with ThreadPoolExecutor(max_workers=10) as executor: + futures = { + executor.submit(stream.read): stream + for stream in streams + } + + for future in as_completed(futures): + stream = futures[future] + + try: + response_data = future.result() + + stream.rule.error = None + stream.rule.succeeded = True + stream.rule.last_suceeded = timezone.now() + + yield response_data + except StreamException as e: + stream.rule.error = e.message + stream.rule.succeeded = False + + yield ({"entries": []}, stream) + finally: + stream.rule.save() + + +class FeedCollector(Collector): + builder = FeedBuilder + client = FeedClient + + +class FeedDuplicateHandler: + def __init__(self, rule): + self.queryset = rule.post_set.all() + + def __enter__(self): + self.existing_identifiers = self.queryset.filter(remote_identifier__isnull=False).values_list( + "remote_identifier", flat=True + ) + return self + + def __exit__(self, *args, **kwargs): + pass + + def check(self, instances): + for instance in instances: + if instance.remote_identifier in self.existing_identifiers: + existing_post = self.handle_duplicate(instance) + + if existing_post: + yield existing_post + continue + elif not instance.remote_identifier and self.in_database(instance): + continue + + yield instance + + def in_database(self, entry): + values = { + "url": entry.url, + "title": entry.title, + "body": entry.body, + "publication_date": entry.publication_date + } + + for existing_entry in self.queryset.order_by("-publication_date")[:50]: + if self.is_duplicate(existing_entry, values): + return True + + def is_duplicate(self, existing_entry, values): + for key, value in values.items(): + existing_value = getattr(existing_entry, key, object()) + if existing_value != value: + return False + + return True + + def handle_duplicate(self, instance): + try: + existing_instance = self.queryset.get( + remote_identifier=instance.remote_identifier, + ) + except ObjectDoesNotExist: + return + + for field in instance._meta.get_fields(): + getattr(existing_instance, field.name, object()) + new_value = getattr(instance, field.name, object()) + + if new_value and field.name != "id": + setattr(existing_instance, field.name, new_value) + + return existing_instance diff --git a/src/newsreader/news/collection/management/commands/collect.py b/src/newsreader/news/collection/management/commands/collect.py new file mode 100644 index 0000000..c72301f --- /dev/null +++ b/src/newsreader/news/collection/management/commands/collect.py @@ -0,0 +1,14 @@ +from django.core.management.base import BaseCommand, CommandError + +from newsreader.news.collection.feed import FeedCollector +from newsreader.news.collection.models import CollectionRule + + +class Command(BaseCommand): + help = 'Collects Atom/RSS feeds' + + def handle(self, *args, **options): + CollectionRule.objects.all() + + collector = FeedCollector() + collector.collect() diff --git a/src/newsreader/news/collection/migrations/0001_initial.py b/src/newsreader/news/collection/migrations/0001_initial.py new file mode 100644 index 0000000..354a97f --- /dev/null +++ b/src/newsreader/news/collection/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 2.2 on 2019-04-10 20:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name='CollectionRule', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID' + ) + ), + ('name', models.CharField(max_length=100)), + ('url', models.URLField()), + ('last_suceeded', models.DateTimeField()), + ('succeeded', models.BooleanField(default=False)), + ], + ), + ] diff --git a/src/newsreader/news/collection/migrations/0002_auto_20190410_2028.py b/src/newsreader/news/collection/migrations/0002_auto_20190410_2028.py new file mode 100644 index 0000000..9c0807e --- /dev/null +++ b/src/newsreader/news/collection/migrations/0002_auto_20190410_2028.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2 on 2019-04-10 20:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('collection', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='collectionrule', + name='last_suceeded', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/src/newsreader/news/collection/migrations/0003_collectionrule_category.py b/src/newsreader/news/collection/migrations/0003_collectionrule_category.py new file mode 100644 index 0000000..4c3f267 --- /dev/null +++ b/src/newsreader/news/collection/migrations/0003_collectionrule_category.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2 on 2019-05-20 20:06 + +import django.db.models.deletion + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('posts', '0002_auto_20190520_2206'), + ('collection', '0002_auto_20190410_2028'), + ] + + operations = [ + migrations.AddField( + model_name='collectionrule', + name='category', + field=models.ForeignKey( + blank=True, + help_text='Posts from this rule will be tagged with this category', + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to='posts.Category', + verbose_name='Category' + ), + ), + ] diff --git a/src/newsreader/news/collection/migrations/0004_collectionrule_timezone.py b/src/newsreader/news/collection/migrations/0004_collectionrule_timezone.py new file mode 100644 index 0000000..e5943dc --- /dev/null +++ b/src/newsreader/news/collection/migrations/0004_collectionrule_timezone.py @@ -0,0 +1,517 @@ +# Generated by Django 2.2 on 2019-05-20 20:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('collection', '0003_collectionrule_category'), + ] + + operations = [ + migrations.AddField( + model_name='collectionrule', + name='timezone', + field=models.CharField( + choices=[ + ('Africa/Abidjan', 'Africa/Abidjan'), + ('Africa/Accra', 'Africa/Accra'), + ('Africa/Addis_Ababa', 'Africa/Addis_Ababa'), + ('Africa/Algiers', 'Africa/Algiers'), + ('Africa/Asmara', + 'Africa/Asmara'), ('Africa/Asmera', 'Africa/Asmera'), + ('Africa/Bamako', + 'Africa/Bamako'), ('Africa/Bangui', 'Africa/Bangui'), + ('Africa/Banjul', 'Africa/Banjul'), + ('Africa/Bissau', 'Africa/Bissau'), + ('Africa/Blantyre', 'Africa/Blantyre'), + ('Africa/Brazzaville', 'Africa/Brazzaville'), + ('Africa/Bujumbura', 'Africa/Bujumbura'), + ('Africa/Cairo', 'Africa/Cairo'), + ('Africa/Casablanca', 'Africa/Casablanca'), + ('Africa/Ceuta', + 'Africa/Ceuta'), ('Africa/Conakry', 'Africa/Conakry'), + ('Africa/Dakar', 'Africa/Dakar'), + ('Africa/Dar_es_Salaam', 'Africa/Dar_es_Salaam'), + ('Africa/Djibouti', 'Africa/Djibouti'), + ('Africa/Douala', 'Africa/Douala'), + ('Africa/El_Aaiun', 'Africa/El_Aaiun'), + ('Africa/Freetown', 'Africa/Freetown'), + ('Africa/Gaborone', 'Africa/Gaborone'), + ('Africa/Harare', 'Africa/Harare'), + ('Africa/Johannesburg', 'Africa/Johannesburg'), + ('Africa/Juba', 'Africa/Juba'), ('Africa/Kampala', 'Africa/Kampala'), + ('Africa/Khartoum', 'Africa/Khartoum'), + ('Africa/Kigali', 'Africa/Kigali'), + ('Africa/Kinshasa', 'Africa/Kinshasa'), + ('Africa/Lagos', 'Africa/Lagos'), + ('Africa/Libreville', 'Africa/Libreville'), + ('Africa/Lome', 'Africa/Lome'), ('Africa/Luanda', 'Africa/Luanda'), + ('Africa/Lubumbashi', 'Africa/Lubumbashi'), + ('Africa/Lusaka', + 'Africa/Lusaka'), ('Africa/Malabo', 'Africa/Malabo'), + ('Africa/Maputo', 'Africa/Maputo'), + ('Africa/Maseru', 'Africa/Maseru'), + ('Africa/Mbabane', 'Africa/Mbabane'), + ('Africa/Mogadishu', 'Africa/Mogadishu'), + ('Africa/Monrovia', 'Africa/Monrovia'), + ('Africa/Nairobi', 'Africa/Nairobi'), + ('Africa/Ndjamena', 'Africa/Ndjamena'), + ('Africa/Niamey', 'Africa/Niamey'), + ('Africa/Nouakchott', 'Africa/Nouakchott'), + ('Africa/Ouagadougou', 'Africa/Ouagadougou'), + ('Africa/Porto-Novo', 'Africa/Porto-Novo'), + ('Africa/Sao_Tome', 'Africa/Sao_Tome'), + ('Africa/Timbuktu', 'Africa/Timbuktu'), + ('Africa/Tripoli', 'Africa/Tripoli'), + ('Africa/Tunis', 'Africa/Tunis'), + ('Africa/Windhoek', 'Africa/Windhoek'), + ('America/Adak', 'America/Adak'), + ('America/Anchorage', 'America/Anchorage'), + ('America/Anguilla', 'America/Anguilla'), + ('America/Antigua', 'America/Antigua'), + ('America/Araguaina', 'America/Araguaina'), + ('America/Argentina/Buenos_Aires', 'America/Argentina/Buenos_Aires'), + ('America/Argentina/Catamarca', 'America/Argentina/Catamarca'), + ( + 'America/Argentina/ComodRivadavia', + 'America/Argentina/ComodRivadavia' + ), ('America/Argentina/Cordoba', 'America/Argentina/Cordoba'), + ('America/Argentina/Jujuy', 'America/Argentina/Jujuy'), + ('America/Argentina/La_Rioja', 'America/Argentina/La_Rioja'), + ('America/Argentina/Mendoza', 'America/Argentina/Mendoza'), + ('America/Argentina/Rio_Gallegos', 'America/Argentina/Rio_Gallegos'), + ('America/Argentina/Salta', 'America/Argentina/Salta'), + ('America/Argentina/San_Juan', 'America/Argentina/San_Juan'), + ('America/Argentina/San_Luis', 'America/Argentina/San_Luis'), + ('America/Argentina/Tucuman', 'America/Argentina/Tucuman'), + ('America/Argentina/Ushuaia', 'America/Argentina/Ushuaia'), + ('America/Aruba', 'America/Aruba'), + ('America/Asuncion', 'America/Asuncion'), + ('America/Atikokan', 'America/Atikokan'), + ('America/Atka', 'America/Atka'), ('America/Bahia', 'America/Bahia'), + ('America/Bahia_Banderas', 'America/Bahia_Banderas'), + ('America/Barbados', 'America/Barbados'), + ('America/Belem', 'America/Belem'), + ('America/Belize', 'America/Belize'), + ('America/Blanc-Sablon', 'America/Blanc-Sablon'), + ('America/Boa_Vista', 'America/Boa_Vista'), + ('America/Bogota', 'America/Bogota'), + ('America/Boise', 'America/Boise'), + ('America/Buenos_Aires', 'America/Buenos_Aires'), + ('America/Cambridge_Bay', 'America/Cambridge_Bay'), + ('America/Campo_Grande', 'America/Campo_Grande'), + ('America/Cancun', 'America/Cancun'), + ('America/Caracas', 'America/Caracas'), + ('America/Catamarca', 'America/Catamarca'), + ('America/Cayenne', 'America/Cayenne'), + ('America/Cayman', 'America/Cayman'), + ('America/Chicago', 'America/Chicago'), + ('America/Chihuahua', 'America/Chihuahua'), + ('America/Coral_Harbour', 'America/Coral_Harbour'), + ('America/Cordoba', 'America/Cordoba'), + ('America/Costa_Rica', 'America/Costa_Rica'), + ('America/Creston', 'America/Creston'), + ('America/Cuiaba', 'America/Cuiaba'), + ('America/Curacao', 'America/Curacao'), + ('America/Danmarkshavn', 'America/Danmarkshavn'), + ('America/Dawson', 'America/Dawson'), + ('America/Dawson_Creek', 'America/Dawson_Creek'), + ('America/Denver', 'America/Denver'), + ('America/Detroit', 'America/Detroit'), + ('America/Dominica', 'America/Dominica'), + ('America/Edmonton', 'America/Edmonton'), + ('America/Eirunepe', 'America/Eirunepe'), + ('America/El_Salvador', 'America/El_Salvador'), + ('America/Ensenada', 'America/Ensenada'), + ('America/Fort_Nelson', 'America/Fort_Nelson'), + ('America/Fort_Wayne', 'America/Fort_Wayne'), + ('America/Fortaleza', 'America/Fortaleza'), + ('America/Glace_Bay', 'America/Glace_Bay'), + ('America/Godthab', 'America/Godthab'), + ('America/Goose_Bay', 'America/Goose_Bay'), + ('America/Grand_Turk', 'America/Grand_Turk'), + ('America/Grenada', 'America/Grenada'), + ('America/Guadeloupe', 'America/Guadeloupe'), + ('America/Guatemala', 'America/Guatemala'), + ('America/Guayaquil', 'America/Guayaquil'), + ('America/Guyana', 'America/Guyana'), + ('America/Halifax', 'America/Halifax'), + ('America/Havana', 'America/Havana'), + ('America/Hermosillo', 'America/Hermosillo'), + ('America/Indiana/Indianapolis', 'America/Indiana/Indianapolis'), + ('America/Indiana/Knox', 'America/Indiana/Knox'), + ('America/Indiana/Marengo', 'America/Indiana/Marengo'), + ('America/Indiana/Petersburg', 'America/Indiana/Petersburg'), + ('America/Indiana/Tell_City', 'America/Indiana/Tell_City'), + ('America/Indiana/Vevay', 'America/Indiana/Vevay'), + ('America/Indiana/Vincennes', 'America/Indiana/Vincennes'), + ('America/Indiana/Winamac', 'America/Indiana/Winamac'), + ('America/Indianapolis', 'America/Indianapolis'), + ('America/Inuvik', 'America/Inuvik'), + ('America/Iqaluit', 'America/Iqaluit'), + ('America/Jamaica', 'America/Jamaica'), + ('America/Jujuy', 'America/Jujuy'), + ('America/Juneau', 'America/Juneau'), + ('America/Kentucky/Louisville', 'America/Kentucky/Louisville'), + ('America/Kentucky/Monticello', 'America/Kentucky/Monticello'), + ('America/Knox_IN', 'America/Knox_IN'), + ('America/Kralendijk', 'America/Kralendijk'), + ('America/La_Paz', + 'America/La_Paz'), ('America/Lima', 'America/Lima'), + ('America/Los_Angeles', 'America/Los_Angeles'), + ('America/Louisville', 'America/Louisville'), + ('America/Lower_Princes', 'America/Lower_Princes'), + ('America/Maceio', 'America/Maceio'), + ('America/Managua', 'America/Managua'), + ('America/Manaus', 'America/Manaus'), + ('America/Marigot', 'America/Marigot'), + ('America/Martinique', 'America/Martinique'), + ('America/Matamoros', 'America/Matamoros'), + ('America/Mazatlan', 'America/Mazatlan'), + ('America/Mendoza', 'America/Mendoza'), + ('America/Menominee', 'America/Menominee'), + ('America/Merida', 'America/Merida'), + ('America/Metlakatla', 'America/Metlakatla'), + ('America/Mexico_City', 'America/Mexico_City'), + ('America/Miquelon', 'America/Miquelon'), + ('America/Moncton', 'America/Moncton'), + ('America/Monterrey', 'America/Monterrey'), + ('America/Montevideo', 'America/Montevideo'), + ('America/Montreal', 'America/Montreal'), + ('America/Montserrat', 'America/Montserrat'), + ('America/Nassau', 'America/Nassau'), + ('America/New_York', 'America/New_York'), + ('America/Nipigon', 'America/Nipigon'), + ('America/Nome', 'America/Nome'), + ('America/Noronha', 'America/Noronha'), + ('America/North_Dakota/Beulah', 'America/North_Dakota/Beulah'), + ('America/North_Dakota/Center', 'America/North_Dakota/Center'), + ('America/North_Dakota/New_Salem', 'America/North_Dakota/New_Salem'), + ('America/Ojinaga', 'America/Ojinaga'), + ('America/Panama', 'America/Panama'), + ('America/Pangnirtung', 'America/Pangnirtung'), + ('America/Paramaribo', 'America/Paramaribo'), + ('America/Phoenix', 'America/Phoenix'), + ('America/Port-au-Prince', 'America/Port-au-Prince'), + ('America/Port_of_Spain', 'America/Port_of_Spain'), + ('America/Porto_Acre', 'America/Porto_Acre'), + ('America/Porto_Velho', 'America/Porto_Velho'), + ('America/Puerto_Rico', 'America/Puerto_Rico'), + ('America/Punta_Arenas', 'America/Punta_Arenas'), + ('America/Rainy_River', 'America/Rainy_River'), + ('America/Rankin_Inlet', 'America/Rankin_Inlet'), + ('America/Recife', 'America/Recife'), + ('America/Regina', 'America/Regina'), + ('America/Resolute', 'America/Resolute'), + ('America/Rio_Branco', 'America/Rio_Branco'), + ('America/Rosario', 'America/Rosario'), + ('America/Santa_Isabel', 'America/Santa_Isabel'), + ('America/Santarem', 'America/Santarem'), + ('America/Santiago', 'America/Santiago'), + ('America/Santo_Domingo', 'America/Santo_Domingo'), + ('America/Sao_Paulo', 'America/Sao_Paulo'), + ('America/Scoresbysund', 'America/Scoresbysund'), + ('America/Shiprock', 'America/Shiprock'), + ('America/Sitka', 'America/Sitka'), + ('America/St_Barthelemy', 'America/St_Barthelemy'), + ('America/St_Johns', 'America/St_Johns'), + ('America/St_Kitts', 'America/St_Kitts'), + ('America/St_Lucia', 'America/St_Lucia'), + ('America/St_Thomas', 'America/St_Thomas'), + ('America/St_Vincent', 'America/St_Vincent'), + ('America/Swift_Current', 'America/Swift_Current'), + ('America/Tegucigalpa', 'America/Tegucigalpa'), + ('America/Thule', 'America/Thule'), + ('America/Thunder_Bay', 'America/Thunder_Bay'), + ('America/Tijuana', 'America/Tijuana'), + ('America/Toronto', 'America/Toronto'), + ('America/Tortola', 'America/Tortola'), + ('America/Vancouver', 'America/Vancouver'), + ('America/Virgin', 'America/Virgin'), + ('America/Whitehorse', 'America/Whitehorse'), + ('America/Winnipeg', 'America/Winnipeg'), + ('America/Yakutat', 'America/Yakutat'), + ('America/Yellowknife', 'America/Yellowknife'), + ('Antarctica/Casey', 'Antarctica/Casey'), + ('Antarctica/Davis', 'Antarctica/Davis'), + ('Antarctica/DumontDUrville', 'Antarctica/DumontDUrville'), + ('Antarctica/Macquarie', 'Antarctica/Macquarie'), + ('Antarctica/Mawson', 'Antarctica/Mawson'), + ('Antarctica/McMurdo', 'Antarctica/McMurdo'), + ('Antarctica/Palmer', 'Antarctica/Palmer'), + ('Antarctica/Rothera', 'Antarctica/Rothera'), + ('Antarctica/South_Pole', 'Antarctica/South_Pole'), + ('Antarctica/Syowa', 'Antarctica/Syowa'), + ('Antarctica/Troll', 'Antarctica/Troll'), + ('Antarctica/Vostok', 'Antarctica/Vostok'), + ('Arctic/Longyearbyen', 'Arctic/Longyearbyen'), + ('Asia/Aden', 'Asia/Aden'), ('Asia/Almaty', 'Asia/Almaty'), + ('Asia/Amman', 'Asia/Amman'), ('Asia/Anadyr', 'Asia/Anadyr'), + ('Asia/Aqtau', 'Asia/Aqtau'), ('Asia/Aqtobe', 'Asia/Aqtobe'), + ('Asia/Ashgabat', 'Asia/Ashgabat'), + ('Asia/Ashkhabad', 'Asia/Ashkhabad'), ('Asia/Atyrau', 'Asia/Atyrau'), + ('Asia/Baghdad', 'Asia/Baghdad'), ('Asia/Bahrain', 'Asia/Bahrain'), + ('Asia/Baku', 'Asia/Baku'), ('Asia/Bangkok', 'Asia/Bangkok'), + ('Asia/Barnaul', 'Asia/Barnaul'), ('Asia/Beirut', 'Asia/Beirut'), + ('Asia/Bishkek', 'Asia/Bishkek'), ('Asia/Brunei', 'Asia/Brunei'), + ('Asia/Calcutta', 'Asia/Calcutta'), ('Asia/Chita', 'Asia/Chita'), + ('Asia/Choibalsan', 'Asia/Choibalsan'), + ('Asia/Chongqing', 'Asia/Chongqing'), + ('Asia/Chungking', + 'Asia/Chungking'), ('Asia/Colombo', 'Asia/Colombo'), + ('Asia/Dacca', 'Asia/Dacca'), ('Asia/Damascus', 'Asia/Damascus'), + ('Asia/Dhaka', 'Asia/Dhaka'), ('Asia/Dili', 'Asia/Dili'), + ('Asia/Dubai', 'Asia/Dubai'), ('Asia/Dushanbe', 'Asia/Dushanbe'), + ('Asia/Famagusta', 'Asia/Famagusta'), ('Asia/Gaza', 'Asia/Gaza'), + ('Asia/Harbin', 'Asia/Harbin'), ('Asia/Hebron', 'Asia/Hebron'), + ('Asia/Ho_Chi_Minh', 'Asia/Ho_Chi_Minh'), + ('Asia/Hong_Kong', 'Asia/Hong_Kong'), ('Asia/Hovd', 'Asia/Hovd'), + ('Asia/Irkutsk', 'Asia/Irkutsk'), ('Asia/Istanbul', 'Asia/Istanbul'), + ('Asia/Jakarta', 'Asia/Jakarta'), ('Asia/Jayapura', 'Asia/Jayapura'), + ('Asia/Jerusalem', 'Asia/Jerusalem'), ('Asia/Kabul', 'Asia/Kabul'), + ('Asia/Kamchatka', 'Asia/Kamchatka'), + ('Asia/Karachi', 'Asia/Karachi'), ('Asia/Kashgar', 'Asia/Kashgar'), + ('Asia/Kathmandu', 'Asia/Kathmandu'), + ('Asia/Katmandu', + 'Asia/Katmandu'), ('Asia/Khandyga', 'Asia/Khandyga'), + ('Asia/Kolkata', 'Asia/Kolkata'), + ('Asia/Krasnoyarsk', 'Asia/Krasnoyarsk'), + ('Asia/Kuala_Lumpur', 'Asia/Kuala_Lumpur'), + ('Asia/Kuching', 'Asia/Kuching'), ('Asia/Kuwait', 'Asia/Kuwait'), + ('Asia/Macao', 'Asia/Macao'), ('Asia/Macau', 'Asia/Macau'), + ('Asia/Magadan', 'Asia/Magadan'), ('Asia/Makassar', 'Asia/Makassar'), + ('Asia/Manila', 'Asia/Manila'), ('Asia/Muscat', 'Asia/Muscat'), + ('Asia/Nicosia', 'Asia/Nicosia'), + ('Asia/Novokuznetsk', 'Asia/Novokuznetsk'), + ('Asia/Novosibirsk', 'Asia/Novosibirsk'), ('Asia/Omsk', 'Asia/Omsk'), + ('Asia/Oral', 'Asia/Oral'), ('Asia/Phnom_Penh', 'Asia/Phnom_Penh'), + ('Asia/Pontianak', 'Asia/Pontianak'), + ('Asia/Pyongyang', 'Asia/Pyongyang'), ('Asia/Qatar', 'Asia/Qatar'), + ('Asia/Qostanay', 'Asia/Qostanay'), + ('Asia/Qyzylorda', + 'Asia/Qyzylorda'), ('Asia/Rangoon', 'Asia/Rangoon'), + ('Asia/Riyadh', 'Asia/Riyadh'), ('Asia/Saigon', 'Asia/Saigon'), + ('Asia/Sakhalin', 'Asia/Sakhalin'), + ('Asia/Samarkand', 'Asia/Samarkand'), ('Asia/Seoul', 'Asia/Seoul'), + ('Asia/Shanghai', 'Asia/Shanghai'), + ('Asia/Singapore', 'Asia/Singapore'), + ('Asia/Srednekolymsk', 'Asia/Srednekolymsk'), + ('Asia/Taipei', 'Asia/Taipei'), ('Asia/Tashkent', 'Asia/Tashkent'), + ('Asia/Tbilisi', 'Asia/Tbilisi'), ('Asia/Tehran', 'Asia/Tehran'), + ('Asia/Tel_Aviv', 'Asia/Tel_Aviv'), ('Asia/Thimbu', 'Asia/Thimbu'), + ('Asia/Thimphu', 'Asia/Thimphu'), ('Asia/Tokyo', 'Asia/Tokyo'), + ('Asia/Tomsk', 'Asia/Tomsk'), + ('Asia/Ujung_Pandang', 'Asia/Ujung_Pandang'), + ('Asia/Ulaanbaatar', 'Asia/Ulaanbaatar'), + ('Asia/Ulan_Bator', + 'Asia/Ulan_Bator'), ('Asia/Urumqi', 'Asia/Urumqi'), + ('Asia/Ust-Nera', 'Asia/Ust-Nera'), + ('Asia/Vientiane', 'Asia/Vientiane'), + ('Asia/Vladivostok', 'Asia/Vladivostok'), + ('Asia/Yakutsk', 'Asia/Yakutsk'), ('Asia/Yangon', 'Asia/Yangon'), + ('Asia/Yekaterinburg', 'Asia/Yekaterinburg'), + ('Asia/Yerevan', 'Asia/Yerevan'), + ('Atlantic/Azores', 'Atlantic/Azores'), + ('Atlantic/Bermuda', 'Atlantic/Bermuda'), + ('Atlantic/Canary', 'Atlantic/Canary'), + ('Atlantic/Cape_Verde', 'Atlantic/Cape_Verde'), + ('Atlantic/Faeroe', 'Atlantic/Faeroe'), + ('Atlantic/Faroe', 'Atlantic/Faroe'), + ('Atlantic/Jan_Mayen', 'Atlantic/Jan_Mayen'), + ('Atlantic/Madeira', 'Atlantic/Madeira'), + ('Atlantic/Reykjavik', 'Atlantic/Reykjavik'), + ('Atlantic/South_Georgia', 'Atlantic/South_Georgia'), + ('Atlantic/St_Helena', 'Atlantic/St_Helena'), + ('Atlantic/Stanley', 'Atlantic/Stanley'), + ('Australia/ACT', 'Australia/ACT'), + ('Australia/Adelaide', 'Australia/Adelaide'), + ('Australia/Brisbane', 'Australia/Brisbane'), + ('Australia/Broken_Hill', 'Australia/Broken_Hill'), + ('Australia/Canberra', 'Australia/Canberra'), + ('Australia/Currie', 'Australia/Currie'), + ('Australia/Darwin', 'Australia/Darwin'), + ('Australia/Eucla', 'Australia/Eucla'), + ('Australia/Hobart', 'Australia/Hobart'), + ('Australia/LHI', 'Australia/LHI'), + ('Australia/Lindeman', 'Australia/Lindeman'), + ('Australia/Lord_Howe', 'Australia/Lord_Howe'), + ('Australia/Melbourne', 'Australia/Melbourne'), + ('Australia/NSW', 'Australia/NSW'), + ('Australia/North', 'Australia/North'), + ('Australia/Perth', 'Australia/Perth'), + ('Australia/Queensland', 'Australia/Queensland'), + ('Australia/South', 'Australia/South'), + ('Australia/Sydney', 'Australia/Sydney'), + ('Australia/Tasmania', 'Australia/Tasmania'), + ('Australia/Victoria', 'Australia/Victoria'), + ('Australia/West', 'Australia/West'), + ('Australia/Yancowinna', 'Australia/Yancowinna'), + ('Brazil/Acre', 'Brazil/Acre'), + ('Brazil/DeNoronha', 'Brazil/DeNoronha'), + ('Brazil/East', 'Brazil/East'), ('Brazil/West', 'Brazil/West'), + ('CET', 'CET'), ('CST6CDT', 'CST6CDT'), + ('Canada/Atlantic', 'Canada/Atlantic'), + ('Canada/Central', 'Canada/Central'), + ('Canada/Eastern', 'Canada/Eastern'), + ('Canada/Mountain', 'Canada/Mountain'), + ('Canada/Newfoundland', 'Canada/Newfoundland'), + ('Canada/Pacific', 'Canada/Pacific'), + ('Canada/Saskatchewan', 'Canada/Saskatchewan'), + ('Canada/Yukon', 'Canada/Yukon'), + ('Chile/Continental', 'Chile/Continental'), + ('Chile/EasterIsland', 'Chile/EasterIsland'), ('Cuba', 'Cuba'), + ('EET', 'EET'), ('EST', 'EST'), ('EST5EDT', 'EST5EDT'), + ('Egypt', 'Egypt'), ('Eire', 'Eire'), ('Etc/GMT', 'Etc/GMT'), + ('Etc/GMT+0', 'Etc/GMT+0'), ('Etc/GMT+1', 'Etc/GMT+1'), + ('Etc/GMT+10', 'Etc/GMT+10'), ('Etc/GMT+11', 'Etc/GMT+11'), + ('Etc/GMT+12', 'Etc/GMT+12'), ('Etc/GMT+2', 'Etc/GMT+2'), + ('Etc/GMT+3', 'Etc/GMT+3'), ('Etc/GMT+4', 'Etc/GMT+4'), + ('Etc/GMT+5', 'Etc/GMT+5'), ('Etc/GMT+6', 'Etc/GMT+6'), + ('Etc/GMT+7', 'Etc/GMT+7'), ('Etc/GMT+8', 'Etc/GMT+8'), + ('Etc/GMT+9', 'Etc/GMT+9'), ('Etc/GMT-0', 'Etc/GMT-0'), + ('Etc/GMT-1', 'Etc/GMT-1'), ('Etc/GMT-10', 'Etc/GMT-10'), + ('Etc/GMT-11', 'Etc/GMT-11'), ('Etc/GMT-12', 'Etc/GMT-12'), + ('Etc/GMT-13', 'Etc/GMT-13'), ('Etc/GMT-14', 'Etc/GMT-14'), + ('Etc/GMT-2', 'Etc/GMT-2'), ('Etc/GMT-3', 'Etc/GMT-3'), + ('Etc/GMT-4', 'Etc/GMT-4'), ('Etc/GMT-5', 'Etc/GMT-5'), + ('Etc/GMT-6', 'Etc/GMT-6'), ('Etc/GMT-7', 'Etc/GMT-7'), + ('Etc/GMT-8', 'Etc/GMT-8'), ('Etc/GMT-9', 'Etc/GMT-9'), + ('Etc/GMT0', 'Etc/GMT0'), ('Etc/Greenwich', 'Etc/Greenwich'), + ('Etc/UCT', 'Etc/UCT'), ('Etc/UTC', 'Etc/UTC'), + ('Etc/Universal', 'Etc/Universal'), ('Etc/Zulu', 'Etc/Zulu'), + ('Europe/Amsterdam', 'Europe/Amsterdam'), + ('Europe/Andorra', 'Europe/Andorra'), + ('Europe/Astrakhan', 'Europe/Astrakhan'), + ('Europe/Athens', 'Europe/Athens'), + ('Europe/Belfast', 'Europe/Belfast'), + ('Europe/Belgrade', 'Europe/Belgrade'), + ('Europe/Berlin', 'Europe/Berlin'), + ('Europe/Bratislava', 'Europe/Bratislava'), + ('Europe/Brussels', 'Europe/Brussels'), + ('Europe/Bucharest', 'Europe/Bucharest'), + ('Europe/Budapest', 'Europe/Budapest'), + ('Europe/Busingen', 'Europe/Busingen'), + ('Europe/Chisinau', 'Europe/Chisinau'), + ('Europe/Copenhagen', 'Europe/Copenhagen'), + ('Europe/Dublin', 'Europe/Dublin'), + ('Europe/Gibraltar', 'Europe/Gibraltar'), + ('Europe/Guernsey', 'Europe/Guernsey'), + ('Europe/Helsinki', 'Europe/Helsinki'), + ('Europe/Isle_of_Man', 'Europe/Isle_of_Man'), + ('Europe/Istanbul', 'Europe/Istanbul'), + ('Europe/Jersey', 'Europe/Jersey'), + ('Europe/Kaliningrad', 'Europe/Kaliningrad'), + ('Europe/Kiev', 'Europe/Kiev'), ('Europe/Kirov', 'Europe/Kirov'), + ('Europe/Lisbon', 'Europe/Lisbon'), + ('Europe/Ljubljana', 'Europe/Ljubljana'), + ('Europe/London', 'Europe/London'), + ('Europe/Luxembourg', 'Europe/Luxembourg'), + ('Europe/Madrid', 'Europe/Madrid'), ('Europe/Malta', 'Europe/Malta'), + ('Europe/Mariehamn', 'Europe/Mariehamn'), + ('Europe/Minsk', 'Europe/Minsk'), ('Europe/Monaco', 'Europe/Monaco'), + ('Europe/Moscow', 'Europe/Moscow'), + ('Europe/Nicosia', 'Europe/Nicosia'), ('Europe/Oslo', 'Europe/Oslo'), + ('Europe/Paris', 'Europe/Paris'), + ('Europe/Podgorica', 'Europe/Podgorica'), + ('Europe/Prague', 'Europe/Prague'), ('Europe/Riga', 'Europe/Riga'), + ('Europe/Rome', 'Europe/Rome'), ('Europe/Samara', 'Europe/Samara'), + ('Europe/San_Marino', 'Europe/San_Marino'), + ('Europe/Sarajevo', 'Europe/Sarajevo'), + ('Europe/Saratov', 'Europe/Saratov'), + ('Europe/Simferopol', 'Europe/Simferopol'), + ('Europe/Skopje', 'Europe/Skopje'), ('Europe/Sofia', 'Europe/Sofia'), + ('Europe/Stockholm', 'Europe/Stockholm'), + ('Europe/Tallinn', 'Europe/Tallinn'), + ('Europe/Tirane', 'Europe/Tirane'), + ('Europe/Tiraspol', 'Europe/Tiraspol'), + ('Europe/Ulyanovsk', 'Europe/Ulyanovsk'), + ('Europe/Uzhgorod', 'Europe/Uzhgorod'), + ('Europe/Vaduz', + 'Europe/Vaduz'), ('Europe/Vatican', 'Europe/Vatican'), + ('Europe/Vienna', 'Europe/Vienna'), + ('Europe/Vilnius', 'Europe/Vilnius'), + ('Europe/Volgograd', 'Europe/Volgograd'), + ('Europe/Warsaw', + 'Europe/Warsaw'), ('Europe/Zagreb', 'Europe/Zagreb'), + ('Europe/Zaporozhye', 'Europe/Zaporozhye'), + ('Europe/Zurich', 'Europe/Zurich'), ('GB', 'GB'), + ('GB-Eire', 'GB-Eire'), ('GMT', 'GMT'), ('GMT+0', 'GMT+0'), + ('GMT-0', 'GMT-0'), ('GMT0', 'GMT0'), ('Greenwich', 'Greenwich'), + ('HST', 'HST'), ('Hongkong', 'Hongkong'), ('Iceland', 'Iceland'), + ('Indian/Antananarivo', 'Indian/Antananarivo'), + ('Indian/Chagos', 'Indian/Chagos'), + ('Indian/Christmas', 'Indian/Christmas'), + ('Indian/Cocos', 'Indian/Cocos'), ('Indian/Comoro', 'Indian/Comoro'), + ('Indian/Kerguelen', 'Indian/Kerguelen'), + ('Indian/Mahe', + 'Indian/Mahe'), ('Indian/Maldives', 'Indian/Maldives'), + ('Indian/Mauritius', 'Indian/Mauritius'), + ('Indian/Mayotte', 'Indian/Mayotte'), + ('Indian/Reunion', 'Indian/Reunion'), ('Iran', 'Iran'), + ('Israel', 'Israel'), ('Jamaica', 'Jamaica'), ('Japan', 'Japan'), + ('Kwajalein', 'Kwajalein'), ('Libya', 'Libya'), ('MET', 'MET'), + ('MST', 'MST'), ('MST7MDT', 'MST7MDT'), + ('Mexico/BajaNorte', 'Mexico/BajaNorte'), + ('Mexico/BajaSur', 'Mexico/BajaSur'), + ('Mexico/General', 'Mexico/General'), ('NZ', 'NZ'), + ('NZ-CHAT', 'NZ-CHAT'), ('Navajo', 'Navajo'), ('PRC', 'PRC'), + ('PST8PDT', 'PST8PDT'), ('Pacific/Apia', 'Pacific/Apia'), + ('Pacific/Auckland', 'Pacific/Auckland'), + ('Pacific/Bougainville', 'Pacific/Bougainville'), + ('Pacific/Chatham', 'Pacific/Chatham'), + ('Pacific/Chuuk', 'Pacific/Chuuk'), + ('Pacific/Easter', 'Pacific/Easter'), + ('Pacific/Efate', 'Pacific/Efate'), + ('Pacific/Enderbury', 'Pacific/Enderbury'), + ('Pacific/Fakaofo', 'Pacific/Fakaofo'), + ('Pacific/Fiji', 'Pacific/Fiji'), + ('Pacific/Funafuti', 'Pacific/Funafuti'), + ('Pacific/Galapagos', 'Pacific/Galapagos'), + ('Pacific/Gambier', 'Pacific/Gambier'), + ('Pacific/Guadalcanal', 'Pacific/Guadalcanal'), + ('Pacific/Guam', 'Pacific/Guam'), + ('Pacific/Honolulu', 'Pacific/Honolulu'), + ('Pacific/Johnston', 'Pacific/Johnston'), + ('Pacific/Kiritimati', 'Pacific/Kiritimati'), + ('Pacific/Kosrae', 'Pacific/Kosrae'), + ('Pacific/Kwajalein', 'Pacific/Kwajalein'), + ('Pacific/Majuro', 'Pacific/Majuro'), + ('Pacific/Marquesas', 'Pacific/Marquesas'), + ('Pacific/Midway', 'Pacific/Midway'), + ('Pacific/Nauru', 'Pacific/Nauru'), ('Pacific/Niue', 'Pacific/Niue'), + ('Pacific/Norfolk', 'Pacific/Norfolk'), + ('Pacific/Noumea', 'Pacific/Noumea'), + ('Pacific/Pago_Pago', 'Pacific/Pago_Pago'), + ('Pacific/Palau', 'Pacific/Palau'), + ('Pacific/Pitcairn', 'Pacific/Pitcairn'), + ('Pacific/Pohnpei', 'Pacific/Pohnpei'), + ('Pacific/Ponape', 'Pacific/Ponape'), + ('Pacific/Port_Moresby', 'Pacific/Port_Moresby'), + ('Pacific/Rarotonga', 'Pacific/Rarotonga'), + ('Pacific/Saipan', 'Pacific/Saipan'), + ('Pacific/Samoa', 'Pacific/Samoa'), + ('Pacific/Tahiti', 'Pacific/Tahiti'), + ('Pacific/Tarawa', 'Pacific/Tarawa'), + ('Pacific/Tongatapu', 'Pacific/Tongatapu'), + ('Pacific/Truk', 'Pacific/Truk'), ('Pacific/Wake', 'Pacific/Wake'), + ('Pacific/Wallis', 'Pacific/Wallis'), ('Pacific/Yap', 'Pacific/Yap'), + ('Poland', 'Poland'), ('Portugal', 'Portugal'), ('ROC', 'ROC'), + ('ROK', 'ROK'), ('Singapore', 'Singapore'), ('Turkey', 'Turkey'), + ('UCT', 'UCT'), ('US/Alaska', 'US/Alaska'), + ('US/Aleutian', 'US/Aleutian'), ('US/Arizona', 'US/Arizona'), + ('US/Central', 'US/Central'), ('US/East-Indiana', 'US/East-Indiana'), + ('US/Eastern', 'US/Eastern'), ('US/Hawaii', 'US/Hawaii'), + ('US/Indiana-Starke', 'US/Indiana-Starke'), + ('US/Michigan', 'US/Michigan'), ('US/Mountain', 'US/Mountain'), + ('US/Pacific', 'US/Pacific'), ('US/Samoa', + 'US/Samoa'), ('UTC', 'UTC'), + ('Universal', 'Universal'), ('W-SU', 'W-SU'), ('WET', 'WET'), + ('Zulu', 'Zulu') + ], + default='UTC', + max_length=100 + ), + ), + ] diff --git a/src/newsreader/news/collection/migrations/0005_auto_20190521_1941.py b/src/newsreader/news/collection/migrations/0005_auto_20190521_1941.py new file mode 100644 index 0000000..e9ab3d4 --- /dev/null +++ b/src/newsreader/news/collection/migrations/0005_auto_20190521_1941.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2 on 2019-05-21 19:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('collection', '0004_collectionrule_timezone'), + ] + + operations = [ + migrations.AddField( + model_name='collectionrule', + name='favicon', + field=models.ImageField(blank=True, null=True, upload_to=''), + ), + migrations.AddField( + model_name='collectionrule', + name='source', + field=models.CharField(default='source', max_length=100), + preserve_default=False, + ), + ] diff --git a/src/newsreader/news/collection/migrations/0006_collectionrule_error.py b/src/newsreader/news/collection/migrations/0006_collectionrule_error.py new file mode 100644 index 0000000..78843b1 --- /dev/null +++ b/src/newsreader/news/collection/migrations/0006_collectionrule_error.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2 on 2019-06-08 14:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('collection', '0005_auto_20190521_1941'), + ] + + operations = [ + migrations.AddField( + model_name='collectionrule', + name='error', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/src/newsreader/news/collection/migrations/__init__.py b/src/newsreader/news/collection/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/models.py b/src/newsreader/news/collection/models.py new file mode 100644 index 0000000..ffb4131 --- /dev/null +++ b/src/newsreader/news/collection/models.py @@ -0,0 +1,35 @@ +import pytz + +from django.db import models +from django.utils.translation import gettext as _ + + +class CollectionRule(models.Model): + name = models.CharField(max_length=100) + source = models.CharField(max_length=100) + + url = models.URLField() + favicon = models.ImageField(blank=True, null=True) + + timezone = models.CharField( + choices=((timezone, timezone) for timezone in pytz.all_timezones), + max_length=100, + default="UTC", + ) + + category = models.ForeignKey( + "posts.Category", + blank=True, + null=True, + verbose_name=_("Category"), + help_text=_("Posts from this rule will be tagged with this category"), + on_delete=models.SET_NULL + ) + + last_suceeded = models.DateTimeField(blank=True, null=True) + succeeded = models.BooleanField(default=False) + + error = models.CharField(max_length=255, blank=True, null=True) + + def __str__(self): + return self.name diff --git a/src/newsreader/news/collection/response_handler.py b/src/newsreader/news/collection/response_handler.py new file mode 100644 index 0000000..dc33190 --- /dev/null +++ b/src/newsreader/news/collection/response_handler.py @@ -0,0 +1,30 @@ +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamForbiddenException, + StreamNotFoundException, + StreamTimeOutException, +) + + +class ResponseHandler: + message_mapping = { + 404: StreamNotFoundException, + 401: StreamDeniedException, + 403: StreamForbiddenException, + 408: StreamTimeOutException, + } + + def __init__(self, response): + self.response = response + + def __enter__(self): + return self + + def handle_response(self): + status_code = self.response.status_code + + if status_code in self.message_mapping: + raise self.message_mapping[status_code] + + def __exit__(self, *args, **kwargs): + self.response = None diff --git a/src/newsreader/news/collection/tests/__init__.py b/src/newsreader/news/collection/tests/__init__.py new file mode 100644 index 0000000..fb6723f --- /dev/null +++ b/src/newsreader/news/collection/tests/__init__.py @@ -0,0 +1 @@ +from .feed import * diff --git a/src/newsreader/news/collection/tests/factories.py b/src/newsreader/news/collection/tests/factories.py new file mode 100644 index 0000000..6b42292 --- /dev/null +++ b/src/newsreader/news/collection/tests/factories.py @@ -0,0 +1,12 @@ +import factory + +from newsreader.news.collection.models import CollectionRule + + +class CollectionRuleFactory(factory.django.DjangoModelFactory): + class Meta: + model = CollectionRule + + name = factory.Sequence(lambda n: "CollectionRule-{}".format(n)) + source = factory.Faker("name") + url = factory.Faker("url") diff --git a/src/newsreader/news/collection/tests/feed/__init__.py b/src/newsreader/news/collection/tests/feed/__init__.py new file mode 100644 index 0000000..50cea54 --- /dev/null +++ b/src/newsreader/news/collection/tests/feed/__init__.py @@ -0,0 +1,5 @@ +from .builder import * +from .client import * +from .collector import * +from .duplicate_handler import * +from .stream import * diff --git a/src/newsreader/news/collection/tests/feed/builder/__init__.py b/src/newsreader/news/collection/tests/feed/builder/__init__.py new file mode 100644 index 0000000..8baa6e5 --- /dev/null +++ b/src/newsreader/news/collection/tests/feed/builder/__init__.py @@ -0,0 +1 @@ +from .tests import * diff --git a/src/newsreader/news/collection/tests/feed/builder/mock_html.py b/src/newsreader/news/collection/tests/feed/builder/mock_html.py new file mode 100644 index 0000000..788495f --- /dev/null +++ b/src/newsreader/news/collection/tests/feed/builder/mock_html.py @@ -0,0 +1,10 @@ +html_summary = ''' + + + + + +''' diff --git a/src/newsreader/news/collection/tests/feed/builder/mocks.py b/src/newsreader/news/collection/tests/feed/builder/mocks.py new file mode 100644 index 0000000..631f582 --- /dev/null +++ b/src/newsreader/news/collection/tests/feed/builder/mocks.py @@ -0,0 +1,878 @@ +from time import struct_time + +from .mock_html import html_summary + +simple_mock = { + 'bozo': 0, + 'encoding': 'utf-8', + 'entries': [{ + 'author': 'A. Author', + 'guidislink': False, + 'href': '', + 'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '1152', + 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', + 'width': '2048' + }], + 'published': 'Mon, 20 May 2019 16:07:37 GMT', + 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + 'summary': 'Foreign Minister Mohammad Javad Zarif says the US ' + 'president should try showing Iranians some respect.', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': 'Foreign Minister Mohammad Javad ' + 'Zarif says the US president should ' + 'try showing Iranians some ' + 'respect.' + }, + 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': "Trump's 'genocidal taunts' will not " + 'end Iran - Zarif' + } + }], + 'feed': { + 'image': { + 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', + 'link': 'https://www.bbc.co.uk/news/', + 'title': 'BBC News - Home', + 'language': 'en-gb', + 'link': 'https://www.bbc.co.uk/news/' + }, + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'title': 'BBC News - Home', + }, + 'href': 'http://feeds.bbci.co.uk/news/rss.xml', + 'status': 200, + 'version': 'rss20' +} + +multiple_mock = { + 'bozo': 0, + 'encoding': 'utf-8', + 'entries': [ + { + 'author': 'A. Author', + 'guidislink': False, + 'href': '', + 'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '1152', + 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', + 'width': '2048' + }], + 'published': 'Mon, 20 May 2019 16:07:37 GMT', + 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + 'summary': 'Foreign Minister Mohammad Javad Zarif says the US ' + 'president should try showing Iranians some respect.', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': 'Foreign Minister Mohammad Javad ' + 'Zarif says the US president should ' + 'try showing Iranians some ' + 'respect.' + }, + 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': "Trump's 'genocidal taunts' will not " + 'end Iran - Zarif' + } + }, + { + 'author': 'A. Author', + 'guidislink': False, + 'href': '', + 'id': 'https://www.bbc.co.uk/news/technology-48334739', + 'link': 'https://www.bbc.co.uk/news/technology-48334739', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/technology-48334739', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '432', + 'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg', + 'width': '768' + }], + 'published': 'Mon, 20 May 2019 12:19:19 GMT', + 'published_parsed': struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0,)), + 'summary': "Google's move to end business ties with Huawei will " + 'affect current devices and future purchases.', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': "Google's move to end business ties " + 'with Huawei will affect current ' + 'devices and future purchases.' + }, + 'title': "Huawei's Android loss: How it affects you", + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': "Huawei's Android loss: How it " + 'affects you' + } + }, + { + 'author': 'A. Author', + 'guidislink': False, + 'href': '', + 'id': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', + 'link': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '549', + 'url': 'http://c.files.bbci.co.uk/11D67/production/_107036037_lgbtheadjpg.jpg', + 'width': '976' + }], + 'published': 'Mon, 20 May 2019 16:32:38 GMT', + 'published_parsed': struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), + 'summary': 'Police are investigating the messages while an MP ' + 'calls for a protest exclusion zone "to protect ' + 'children".', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': 'Police are investigating the ' + 'messages while an MP calls for a ' + 'protest exclusion zone "to protect ' + 'children".' + }, + 'title': 'Birmingham head teacher threatened over LGBT lessons', + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': 'Birmingham head teacher threatened ' + 'over LGBT lessons' + } + }, + ], + 'feed': { + 'image': { + 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', + 'link': 'https://www.bbc.co.uk/news/', + 'title': 'BBC News - Home', + 'language': 'en-gb', + 'link': 'https://www.bbc.co.uk/news/' + }, + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'title': 'BBC News - Home', + }, + 'href': 'http://feeds.bbci.co.uk/news/rss.xml', + 'status': 200, + 'version': 'rss20' +} + +mock_without_identifier = { + 'bozo': 0, + 'encoding': 'utf-8', + 'entries': [ + { + 'author': 'A. Author', + 'guidislink': False, + 'href': '', + 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '1152', + 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', + 'width': '2048' + }], + 'published': 'Mon, 20 May 2019 16:07:37 GMT', + 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + 'summary': 'Foreign Minister Mohammad Javad Zarif says the US ' + 'president should try showing Iranians some respect.', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': 'Foreign Minister Mohammad Javad ' + 'Zarif says the US president should ' + 'try showing Iranians some ' + 'respect.' + }, + 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': "Trump's 'genocidal taunts' will not " + 'end Iran - Zarif' + } + }, + { + 'author': 'A. Author', + 'guidislink': False, + 'href': '', + 'id': None, + 'link': 'https://www.bbc.co.uk/news/technology-48334739', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/technology-48334739', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '432', + 'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg', + 'width': '768' + }], + 'published': 'Mon, 20 May 2019 12:19:19 GMT', + 'published_parsed': struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0,)), + 'summary': "Google's move to end business ties with Huawei will " + 'affect current devices and future purchases.', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': "Google's move to end business ties " + 'with Huawei will affect current ' + 'devices and future purchases.' + }, + 'title': "Huawei's Android loss: How it affects you", + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': "Huawei's Android loss: How it " + 'affects you' + } + }, + ], + 'feed': { + 'image': { + 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', + 'link': 'https://www.bbc.co.uk/news/', + 'title': 'BBC News - Home', + 'language': 'en-gb', + 'link': 'https://www.bbc.co.uk/news/' + }, + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'title': 'BBC News - Home', + }, + 'href': 'http://feeds.bbci.co.uk/news/rss.xml', + 'status': 200, + 'version': 'rss20' +} + +mock_without_publish_date = { + 'bozo': 0, + 'encoding': 'utf-8', + 'entries': [ + { + 'author': 'A. Author', + 'guidislink': False, + 'href': '', + 'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '1152', + 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', + 'width': '2048' + }], + 'published': None, + 'published_parsed': None, + 'summary': 'Foreign Minister Mohammad Javad Zarif says the US ' + 'president should try showing Iranians some respect.', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': 'Foreign Minister Mohammad Javad ' + 'Zarif says the US president should ' + 'try showing Iranians some ' + 'respect.' + }, + 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': "Trump's 'genocidal taunts' will not " + 'end Iran - Zarif' + } + }, + { + 'author': 'A. Author', + 'guidislink': False, + 'href': '', + 'id': 'https://www.bbc.co.uk/news/technology-48334739', + 'link': 'https://www.bbc.co.uk/news/technology-48334739', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/technology-48334739', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '432', + 'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg', + 'width': '768' + }], + 'summary': "Google's move to end business ties with Huawei will " + 'affect current devices and future purchases.', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': "Google's move to end business ties " + 'with Huawei will affect current ' + 'devices and future purchases.' + }, + 'title': "Huawei's Android loss: How it affects you", + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': "Huawei's Android loss: How it " + 'affects you' + } + }, + ], + 'feed': { + 'image': { + 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', + 'link': 'https://www.bbc.co.uk/news/', + 'title': 'BBC News - Home', + 'language': 'en-gb', + 'link': 'https://www.bbc.co.uk/news/' + }, + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'title': 'BBC News - Home', + }, + 'href': 'http://feeds.bbci.co.uk/news/rss.xml', + 'status': 200, + 'version': 'rss20' +} + +mock_without_url = { + 'bozo': 0, + 'encoding': 'utf-8', + 'entries': [ + { + 'author': 'A. Author', + 'guidislink': False, + 'href': '', + 'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'media_thumbnail': [{ + 'height': '1152', + 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', + 'width': '2048' + }], + 'published': 'Mon, 20 May 2019 16:07:37 GMT', + 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + 'published': None, + 'published_parsed': None, + 'summary': 'Foreign Minister Mohammad Javad Zarif says the US ' + 'president should try showing Iranians some respect.', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': 'Foreign Minister Mohammad Javad ' + 'Zarif says the US president should ' + 'try showing Iranians some ' + 'respect.' + }, + 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': "Trump's 'genocidal taunts' will not " + 'end Iran - Zarif' + } + }, + { + 'author': 'A. Author', + 'guidislink': False, + 'href': '', + 'id': 'https://www.bbc.co.uk/news/technology-48334739', + 'link': None, + 'links': [], + 'media_thumbnail': [{ + 'height': '432', + 'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg', + 'width': '768' + }], + 'published': 'Mon, 20 May 2019 16:07:37 GMT', + 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + + 'summary': "Google's move to end business ties with Huawei will " + 'affect current devices and future purchases.', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': "Google's move to end business ties " + 'with Huawei will affect current ' + 'devices and future purchases.' + }, + 'title': "Huawei's Android loss: How it affects you", + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': "Huawei's Android loss: How it " + 'affects you' + } + }, + ], + 'feed': { + 'image': { + 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', + 'link': 'https://www.bbc.co.uk/news/', + 'title': 'BBC News - Home', + 'language': 'en-gb', + 'link': 'https://www.bbc.co.uk/news/' + }, + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'title': 'BBC News - Home', + }, + 'href': 'http://feeds.bbci.co.uk/news/rss.xml', + 'status': 200, + 'version': 'rss20' +} + +mock_without_body = { + 'bozo': 0, + 'encoding': 'utf-8', + 'entries': [ + { + 'author': 'A. Author', + 'guidislink': False, + 'href': '', + 'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '1152', + 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', + 'width': '2048' + }], + 'published': 'Mon, 20 May 2019 16:07:37 GMT', + 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': "Trump's 'genocidal taunts' will not " + 'end Iran - Zarif' + } + }, + { + 'author': 'A. Author', + 'guidislink': False, + 'href': '', + 'id': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', + 'link': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '549', + 'url': 'http://c.files.bbci.co.uk/11D67/production/_107036037_lgbtheadjpg.jpg', + 'width': '976' + }], + 'published': 'Mon, 20 May 2019 16:32:38 GMT', + 'published_parsed': struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), + 'summary': None, + 'summary_detail': {}, + 'title': 'Birmingham head teacher threatened over LGBT lessons', + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': 'Birmingham head teacher threatened ' + 'over LGBT lessons' + } + }, + ], + 'feed': { + 'image': { + 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', + 'link': 'https://www.bbc.co.uk/news/', + 'title': 'BBC News - Home', + 'language': 'en-gb', + 'link': 'https://www.bbc.co.uk/news/' + }, + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'title': 'BBC News - Home', + }, + 'href': 'http://feeds.bbci.co.uk/news/rss.xml', + 'status': 200, + 'version': 'rss20' +} + +mock_without_author = { + 'bozo': 0, + 'encoding': 'utf-8', + 'entries': [ + { + 'guidislink': False, + 'href': '', + 'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '1152', + 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', + 'width': '2048' + }], + 'published': 'Mon, 20 May 2019 16:07:37 GMT', + 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + 'summary': 'Foreign Minister Mohammad Javad Zarif says the US ' + 'president should try showing Iranians some respect.', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': 'Foreign Minister Mohammad Javad ' + 'Zarif says the US president should ' + 'try showing Iranians some ' + 'respect.' + }, + 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': "Trump's 'genocidal taunts' will not " + 'end Iran - Zarif' + } + }, + { + 'author': None, + 'guidislink': False, + 'href': '', + 'id': 'https://www.bbc.co.uk/news/technology-48334739', + 'link': 'https://www.bbc.co.uk/news/technology-48334739', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/technology-48334739', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '432', + 'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg', + 'width': '768' + }], + 'published': 'Mon, 20 May 2019 12:19:19 GMT', + 'published_parsed': struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0,)), + 'summary': "Google's move to end business ties with Huawei will " + 'affect current devices and future purchases.', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': "Google's move to end business ties " + 'with Huawei will affect current ' + 'devices and future purchases.' + }, + 'title': "Huawei's Android loss: How it affects you", + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': "Huawei's Android loss: How it " + 'affects you' + } + }, + ], + 'feed': { + 'image': { + 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', + 'link': 'https://www.bbc.co.uk/news/', + 'title': 'BBC News - Home', + 'language': 'en-gb', + 'link': 'https://www.bbc.co.uk/news/' + }, + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'title': 'BBC News - Home', + }, + 'href': 'http://feeds.bbci.co.uk/news/rss.xml', + 'status': 200, + 'version': 'rss20' +} + +mock_without_entries = { + 'entries': [], +} + +mock_with_update_entries = { + 'bozo': 0, + 'encoding': 'utf-8', + 'entries': [ + { + 'author': 'A. Author', + 'guidislink': False, + 'href': '', + 'id': '28f79ae4-8f9a-11e9-b143-00163ef6bee7', + 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '1152', + 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', + 'width': '2048' + }], + 'published': 'Mon, 20 May 2019 16:07:37 GMT', + 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + 'summary': 'Foreign Minister Mohammad Javad Zarif says the US ' + 'president should try showing Iranians some respect.', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': 'Foreign Minister Mohammad Javad ' + 'Zarif says the US president should ' + 'try showing Iranians some ' + 'respect.' + }, + 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': "Trump's 'genocidal taunts' will not " + 'end Iran - Zarif' + } + }, + { + 'author': 'A. Author', + 'guidislink': False, + 'href': '', + 'id': 'a5479c66-8fae-11e9-8422-00163ef6bee7', + 'link': 'https://www.bbc.co.uk/news/technology-48334739', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/technology-48334739', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '432', + 'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg', + 'width': '768' + }], + 'published': 'Mon, 20 May 2019 12:19:19 GMT', + 'published_parsed': struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0,)), + 'summary': "Google's move to end business ties with Huawei will " + 'affect current devices and future purchases.', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': "Google's move to end business ties " + 'with Huawei will affect current ' + 'devices and future purchases.' + }, + 'title': "Huawei's Android loss: How it affects you", + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': "Huawei's Android loss: How it " + 'affects you' + } + }, + { + 'author': 'A. Author', + 'guidislink': False, + 'href': '', + 'id': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', + 'link': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '549', + 'url': 'http://c.files.bbci.co.uk/11D67/production/_107036037_lgbtheadjpg.jpg', + 'width': '976' + }], + 'published': 'Mon, 20 May 2019 16:32:38 GMT', + 'published_parsed': struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), + 'summary': 'Police are investigating the messages while an MP ' + 'calls for a protest exclusion zone "to protect ' + 'children".', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': 'Police are investigating the ' + 'messages while an MP calls for a ' + 'protest exclusion zone "to protect ' + 'children".' + }, + 'title': 'Birmingham head teacher threatened over LGBT lessons', + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': 'Birmingham head teacher threatened ' + 'over LGBT lessons' + } + }, + ], + 'feed': { + 'image': { + 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', + 'link': 'https://www.bbc.co.uk/news/', + 'title': 'BBC News - Home', + 'language': 'en-gb', + 'link': 'https://www.bbc.co.uk/news/' + }, + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'title': 'BBC News - Home', + }, + 'href': 'http://feeds.bbci.co.uk/news/rss.xml', + 'status': 200, + 'version': 'rss20' +} + +mock_with_html = { + 'bozo': 0, + 'encoding': 'utf-8', + 'entries': [ + { + 'author': 'A. Author', + 'guidislink': False, + 'href': '', + 'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '1152', + 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', + 'width': '2048' + }], + 'published': 'Mon, 20 May 2019 16:07:37 GMT', + 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + 'summary': html_summary, + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': 'Foreign Minister Mohammad Javad ' + 'Zarif says the US president should ' + 'try showing Iranians some ' + 'respect.' + }, + 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': "Trump's 'genocidal taunts' will not " + 'end Iran - Zarif' + } + }, + ], + 'feed': { + 'image': { + 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', + 'link': 'https://www.bbc.co.uk/news/', + 'title': 'BBC News - Home', + 'language': 'en-gb', + 'link': 'https://www.bbc.co.uk/news/' + }, + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'title': 'BBC News - Home', + }, + 'href': 'http://feeds.bbci.co.uk/news/rss.xml', + 'status': 200, + 'version': 'rss20' +} diff --git a/src/newsreader/news/collection/tests/feed/builder/tests.py b/src/newsreader/news/collection/tests/feed/builder/tests.py new file mode 100644 index 0000000..6e4e44d --- /dev/null +++ b/src/newsreader/news/collection/tests/feed/builder/tests.py @@ -0,0 +1,304 @@ +from datetime import date, datetime, time +from unittest.mock import MagicMock + +import pytz + +from freezegun import freeze_time + +from django.test import TestCase +from django.utils import timezone + +from newsreader.news.collection.feed import FeedBuilder +from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.feed.builder.mocks import * +from newsreader.news.posts.models import Post +from newsreader.news.posts.tests.factories import PostFactory + + +class FeedBuilderTestCase(TestCase): + def setUp(self): + pass + + def test_basic_entry(self): + builder = FeedBuilder + rule = CollectionRuleFactory() + mock_stream = MagicMock(rule=rule) + + with builder((simple_mock, mock_stream,)) as builder: + builder.save() + + post = Post.objects.get() + + d = datetime.combine(date(2019, 5, 20), time(hour=16, minute=7, second=37)) + aware_date = pytz.utc.localize(d) + + self.assertEquals(post.publication_date, aware_date) + self.assertEquals(Post.objects.count(), 1) + + self.assertEquals( + post.remote_identifier, + "https://www.bbc.co.uk/news/world-us-canada-48338168" + ) + + self.assertEquals( + post.url, + "https://www.bbc.co.uk/news/world-us-canada-48338168" + ) + + self.assertEquals( + post.title, + "Trump's 'genocidal taunts' will not end Iran - Zarif" + ) + + def test_multiple_entries(self): + builder = FeedBuilder + rule = CollectionRuleFactory() + mock_stream = MagicMock(rule=rule) + + with builder((multiple_mock, mock_stream,)) as builder: + builder.save() + + posts = Post.objects.order_by("id") + self.assertEquals(Post.objects.count(), 3) + + first_post = posts[0] + second_post = posts[1] + + d = datetime.combine(date(2019, 5, 20), time(hour=16, minute=7, second=37)) + aware_date = pytz.utc.localize(d) + + self.assertEquals(first_post.publication_date, aware_date) + + self.assertEquals( + first_post.remote_identifier, + "https://www.bbc.co.uk/news/world-us-canada-48338168" + ) + + self.assertEquals( + first_post.url, + "https://www.bbc.co.uk/news/world-us-canada-48338168" + ) + + self.assertEquals( + first_post.title, + "Trump's 'genocidal taunts' will not end Iran - Zarif" + ) + + d = datetime.combine(date(2019, 5, 20), time(hour=12, minute=19, second=19)) + aware_date = pytz.utc.localize(d) + + self.assertEquals(second_post.publication_date, aware_date) + + self.assertEquals( + second_post.remote_identifier, + "https://www.bbc.co.uk/news/technology-48334739" + ) + + self.assertEquals( + second_post.url, + "https://www.bbc.co.uk/news/technology-48334739" + ) + + self.assertEquals( + second_post.title, + "Huawei's Android loss: How it affects you" + ) + + def test_entry_without_remote_identifier(self): + builder = FeedBuilder + rule = CollectionRuleFactory() + mock_stream = MagicMock(rule=rule) + + with builder((mock_without_identifier, mock_stream,)) as builder: + builder.save() + + posts = Post.objects.order_by("id") + self.assertEquals(Post.objects.count(), 2) + + first_post = posts[0] + + d = datetime.combine(date(2019, 5, 20), time(hour=16, minute=7, second=37)) + aware_date = pytz.utc.localize(d) + + self.assertEquals(first_post.publication_date, aware_date) + + self.assertEquals(first_post.remote_identifier, None) + + self.assertEquals( + first_post.url, + "https://www.bbc.co.uk/news/world-us-canada-48338168" + ) + + self.assertEquals( + first_post.title, + "Trump's 'genocidal taunts' will not end Iran - Zarif" + ) + + @freeze_time("2019-10-30 12:30:00") + def test_entry_without_publication_date(self): + builder = FeedBuilder + rule = CollectionRuleFactory() + mock_stream = MagicMock(rule=rule) + + with builder((mock_without_publish_date, mock_stream,)) as builder: + builder.save() + + posts = Post.objects.order_by("id") + self.assertEquals(Post.objects.count(), 2) + + first_post = posts[0] + second_post = posts[1] + + self.assertEquals(first_post.created, timezone.now()) + self.assertEquals( + first_post.remote_identifier, + 'https://www.bbc.co.uk/news/world-us-canada-48338168' + ) + + self.assertEquals(second_post.created, timezone.now()) + self.assertEquals( + second_post.remote_identifier, + 'https://www.bbc.co.uk/news/technology-48334739' + ) + + @freeze_time("2019-10-30 12:30:00") + def test_entry_without_url(self): + builder = FeedBuilder + rule = CollectionRuleFactory() + mock_stream = MagicMock(rule=rule) + + with builder((mock_without_url, mock_stream,)) as builder: + builder.save() + + posts = Post.objects.order_by("id") + self.assertEquals(Post.objects.count(), 2) + + first_post = posts[0] + second_post = posts[1] + + self.assertEquals(first_post.created, timezone.now()) + self.assertEquals( + first_post.remote_identifier, + 'https://www.bbc.co.uk/news/world-us-canada-48338168' + ) + + self.assertEquals(second_post.created, timezone.now()) + self.assertEquals( + second_post.remote_identifier, + 'https://www.bbc.co.uk/news/technology-48334739' + ) + + @freeze_time("2019-10-30 12:30:00") + def test_entry_without_body(self): + builder = FeedBuilder + rule = CollectionRuleFactory() + mock_stream = MagicMock(rule=rule) + + with builder((mock_without_body, mock_stream,)) as builder: + builder.save() + + posts = Post.objects.order_by("id") + self.assertEquals(Post.objects.count(), 2) + + first_post = posts[0] + second_post = posts[1] + + self.assertEquals(first_post.created, timezone.now()) + self.assertEquals( + first_post.remote_identifier, + 'https://www.bbc.co.uk/news/world-us-canada-48338168' + ) + + self.assertEquals(second_post.created, timezone.now()) + self.assertEquals( + second_post.remote_identifier, + 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080' + ) + + @freeze_time("2019-10-30 12:30:00") + def test_entry_without_author(self): + builder = FeedBuilder + rule = CollectionRuleFactory() + mock_stream = MagicMock(rule=rule) + + with builder((mock_without_author, mock_stream,)) as builder: + builder.save() + + posts = Post.objects.order_by("id") + self.assertEquals(Post.objects.count(), 2) + + first_post = posts[0] + second_post = posts[1] + + self.assertEquals(first_post.created, timezone.now()) + self.assertEquals( + first_post.remote_identifier, + 'https://www.bbc.co.uk/news/world-us-canada-48338168' + ) + + self.assertEquals(second_post.created, timezone.now()) + self.assertEquals( + second_post.remote_identifier, + 'https://www.bbc.co.uk/news/technology-48334739' + ) + + def test_empty_entries(self): + builder = FeedBuilder + rule = CollectionRuleFactory() + mock_stream = MagicMock(rule=rule) + + with builder((mock_without_entries, mock_stream,)) as builder: + builder.save() + + self.assertEquals(Post.objects.count(), 0) + + def test_update_entries(self): + builder = FeedBuilder + rule = CollectionRuleFactory() + mock_stream = MagicMock(rule=rule) + + existing_first_post = PostFactory.create( + remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", rule=rule + ) + + existing_second_post = PostFactory.create( + remote_identifier="a5479c66-8fae-11e9-8422-00163ef6bee7", rule=rule + ) + + with builder((mock_with_update_entries, mock_stream,)) as builder: + builder.save() + + self.assertEquals(Post.objects.count(), 3) + + existing_first_post.refresh_from_db() + existing_second_post.refresh_from_db() + + self.assertEquals( + existing_first_post.title, + "Trump's 'genocidal taunts' will not end Iran - Zarif" + ) + + self.assertEquals( + existing_second_post.title, + "Huawei's Android loss: How it affects you" + ) + + def test_html_sanitizing(self): + builder = FeedBuilder + rule = CollectionRuleFactory() + mock_stream = MagicMock(rule=rule) + + with builder((mock_with_html, mock_stream,)) as builder: + builder.save() + + post = Post.objects.get() + + self.assertEquals(Post.objects.count(), 1) + + self.assertTrue("" not in post.body) + self.assertTrue("" not in post.body) + self.assertTrue("
" not in post.body) + self.assertTrue("

" not in post.body) + self.assertTrue("" not in post.body) + self.assertTrue('' in post.body) + self.assertTrue("

" in post.body) diff --git a/src/newsreader/news/collection/tests/feed/client/__init__.py b/src/newsreader/news/collection/tests/feed/client/__init__.py new file mode 100644 index 0000000..8baa6e5 --- /dev/null +++ b/src/newsreader/news/collection/tests/feed/client/__init__.py @@ -0,0 +1 @@ +from .tests import * diff --git a/src/newsreader/news/collection/tests/feed/client/mocks.py b/src/newsreader/news/collection/tests/feed/client/mocks.py new file mode 100644 index 0000000..5853eb7 --- /dev/null +++ b/src/newsreader/news/collection/tests/feed/client/mocks.py @@ -0,0 +1,61 @@ +from time import struct_time + +simple_mock = { + 'bozo': 0, + 'encoding': 'utf-8', + 'entries': [{ + 'guidislink': False, + 'href': '', + 'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '1152', + 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', + 'width': '2048' + }], + 'published': 'Mon, 20 May 2019 16:07:37 GMT', + 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + 'summary': 'Foreign Minister Mohammad Javad Zarif says the US ' + 'president should try showing Iranians some respect.', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': 'Foreign Minister Mohammad Javad ' + 'Zarif says the US president should ' + 'try showing Iranians some ' + 'respect.' + }, + 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': "Trump's 'genocidal taunts' will not " + 'end Iran - Zarif' + } + }], + 'feed': { + 'image': { + 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', + 'link': 'https://www.bbc.co.uk/news/', + 'title': 'BBC News - Home', + 'language': 'en-gb', + 'link': 'https://www.bbc.co.uk/news/' + }, + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'title': 'BBC News - Home', + }, + 'href': 'http://feeds.bbci.co.uk/news/rss.xml', + 'status': 200, + 'version': 'rss20' +} diff --git a/src/newsreader/news/collection/tests/feed/client/tests.py b/src/newsreader/news/collection/tests/feed/client/tests.py new file mode 100644 index 0000000..0bb4cdd --- /dev/null +++ b/src/newsreader/news/collection/tests/feed/client/tests.py @@ -0,0 +1,90 @@ +from unittest.mock import MagicMock, patch + +from django.test import TestCase +from django.utils import timezone + +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamException, + StreamFieldException, + StreamNotFoundException, + StreamTimeOutException, +) +from newsreader.news.collection.feed import FeedClient +from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.feed.client.mocks import simple_mock + + +class FeedClientTestCase(TestCase): + def setUp(self): + self.patched_read = patch( + 'newsreader.news.collection.feed.FeedStream.read' + ) + self.mocked_read = self.patched_read.start() + + def tearDown(self): + patch.stopall() + + def test_client_retrieves_single_rules(self): + rule = CollectionRuleFactory.create() + mock_stream = MagicMock(rule=rule) + self.mocked_read.return_value = (simple_mock, mock_stream) + + with FeedClient([rule]) as client: + for data, stream in client: + self.assertEquals(data, simple_mock) + self.assertEquals(stream, mock_stream) + + self.mocked_read.assert_called_once_with() + + def test_client_catches_stream_exception(self): + rule = CollectionRuleFactory.create() + mock_stream = MagicMock(rule=rule) + self.mocked_read.side_effect = StreamException("Stream exception") + + with FeedClient([rule]) as client: + for data, stream in client: + self.assertEquals(data, {"entries": []}) + self.assertEquals(stream.rule.error, "Stream exception") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called_once_with() + + def test_client_catches_stream_not_found_exception(self): + rule = CollectionRuleFactory.create() + mock_stream = MagicMock(rule=rule) + self.mocked_read.side_effect = StreamNotFoundException("Stream not found") + + with FeedClient([rule]) as client: + for data, stream in client: + self.assertEquals(data, {"entries": []}) + self.assertEquals(stream.rule.error, "Stream not found") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called_once_with() + + def test_client_catches_stream_denied_exception(self): + rule = CollectionRuleFactory.create() + mock_stream = MagicMock(rule=rule) + self.mocked_read.side_effect = StreamDeniedException("Stream denied") + + with FeedClient([rule]) as client: + for data, stream in client: + self.assertEquals(data, {"entries": []}) + self.assertEquals(stream.rule.error, "Stream denied") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called_once_with() + + def test_client_catches_stream_timed_out(self): + rule = CollectionRuleFactory.create() + mock_stream = MagicMock(rule=rule) + self.mocked_read.side_effect = StreamTimeOutException("Stream timed out") + + with FeedClient([rule]) as client: + for data, stream in client: + self.assertEquals(data, {"entries": []}) + self.assertEquals(stream.rule.error, "Stream timed out") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called_once_with() diff --git a/src/newsreader/news/collection/tests/feed/collector/__init__.py b/src/newsreader/news/collection/tests/feed/collector/__init__.py new file mode 100644 index 0000000..8baa6e5 --- /dev/null +++ b/src/newsreader/news/collection/tests/feed/collector/__init__.py @@ -0,0 +1 @@ +from .tests import * diff --git a/src/newsreader/news/collection/tests/feed/collector/mocks.py b/src/newsreader/news/collection/tests/feed/collector/mocks.py new file mode 100644 index 0000000..930e977 --- /dev/null +++ b/src/newsreader/news/collection/tests/feed/collector/mocks.py @@ -0,0 +1,430 @@ +from time import struct_time + +multiple_mock = { + 'bozo': 0, + 'encoding': 'utf-8', + 'entries': [ + { + 'guidislink': False, + 'href': '', + 'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '1152', + 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', + 'width': '2048' + }], + 'published': 'Mon, 20 May 2019 16:07:37 GMT', + 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + 'summary': 'Foreign Minister Mohammad Javad Zarif says the US ' + 'president should try showing Iranians some respect.', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': 'Foreign Minister Mohammad Javad ' + 'Zarif says the US president should ' + 'try showing Iranians some ' + 'respect.' + }, + 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': "Trump's 'genocidal taunts' will not " + 'end Iran - Zarif' + } + }, + { + 'guidislink': False, + 'href': '', + 'id': 'https://www.bbc.co.uk/news/technology-48334739', + 'link': 'https://www.bbc.co.uk/news/technology-48334739', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/technology-48334739', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '432', + 'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg', + 'width': '768' + }], + 'published': 'Mon, 20 May 2019 12:19:19 GMT', + 'published_parsed': struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0,)), + 'summary': "Google's move to end business ties with Huawei will " + 'affect current devices and future purchases.', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': "Google's move to end business ties " + 'with Huawei will affect current ' + 'devices and future purchases.' + }, + 'title': "Huawei's Android loss: How it affects you", + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': "Huawei's Android loss: How it " + 'affects you' + } + }, + { + 'guidislink': False, + 'href': '', + 'id': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', + 'link': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '549', + 'url': 'http://c.files.bbci.co.uk/11D67/production/_107036037_lgbtheadjpg.jpg', + 'width': '976' + }], + 'published': 'Mon, 20 May 2019 16:32:38 GMT', + 'published_parsed': struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), + 'summary': 'Police are investigating the messages while an MP ' + 'calls for a protest exclusion zone "to protect ' + 'children".', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': 'Police are investigating the ' + 'messages while an MP calls for a ' + 'protest exclusion zone "to protect ' + 'children".' + }, + 'title': 'Birmingham head teacher threatened over LGBT lessons', + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': 'Birmingham head teacher threatened ' + 'over LGBT lessons' + } + }, + ], + 'feed': { + 'image': { + 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', + 'link': 'https://www.bbc.co.uk/news/', + 'title': 'BBC News - Home', + 'language': 'en-gb', + 'link': 'https://www.bbc.co.uk/news/' + }, + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'title': 'BBC News - Home', + }, + 'href': 'http://feeds.bbci.co.uk/news/rss.xml', + 'status': 200, + 'version': 'rss20' +} + +empty_mock = { + 'bozo': 0, + 'encoding': 'utf-8', + 'entries': [], + 'feed': { + 'image': { + 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', + 'link': 'https://www.bbc.co.uk/news/', + 'title': 'BBC News - Home', + 'language': 'en-gb', + 'link': 'https://www.bbc.co.uk/news/' + }, + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'title': 'BBC News - Home', + }, + 'href': 'http://feeds.bbci.co.uk/news/rss.xml', + 'status': 200, + 'version': 'rss20' +} + +duplicate_mock = { + 'bozo': 0, + 'encoding': 'utf-8', + 'entries': [ + { + 'guidislink': False, + 'href': '', + 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '1152', + 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', + 'width': '2048' + }], + 'published': 'Mon, 20 May 2019 16:07:37 GMT', + 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + 'summary': 'Foreign Minister Mohammad Javad Zarif says the US ' + 'president should try showing Iranians some respect.', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': 'Foreign Minister Mohammad Javad ' + 'Zarif says the US president should ' + 'try showing Iranians some ' + 'respect.' + }, + 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': "Trump's 'genocidal taunts' will not " + 'end Iran - Zarif' + } + }, + { + 'guidislink': False, + 'href': '', + 'link': 'https://www.bbc.co.uk/news/technology-48334739', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/technology-48334739', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '432', + 'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg', + 'width': '768' + }], + 'published': 'Mon, 20 May 2019 12:19:19 GMT', + 'published_parsed': struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0,)), + 'summary': "Google's move to end business ties with Huawei will " + 'affect current devices and future purchases.', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': "Google's move to end business ties " + 'with Huawei will affect current ' + 'devices and future purchases.' + }, + 'title': "Huawei's Android loss: How it affects you", + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': "Huawei's Android loss: How it " + 'affects you' + } + }, + { + 'guidislink': False, + 'href': '', + 'link': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '549', + 'url': 'http://c.files.bbci.co.uk/11D67/production/_107036037_lgbtheadjpg.jpg', + 'width': '976' + }], + 'published': 'Mon, 20 May 2019 16:32:38 GMT', + 'published_parsed': struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), + 'summary': 'Police are investigating the messages while an MP ' + 'calls for a protest exclusion zone "to protect ' + 'children".', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': 'Police are investigating the ' + 'messages while an MP calls for a ' + 'protest exclusion zone "to protect ' + 'children".' + }, + 'title': 'Birmingham head teacher threatened over LGBT lessons', + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': 'Birmingham head teacher threatened ' + 'over LGBT lessons' + } + }, + ], + 'feed': { + 'image': { + 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', + 'link': 'https://www.bbc.co.uk/news/', + 'title': 'BBC News - Home', + 'language': 'en-gb', + 'link': 'https://www.bbc.co.uk/news/' + }, + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'title': 'BBC News - Home', + }, + 'href': 'http://feeds.bbci.co.uk/news/rss.xml', + 'status': 200, + 'version': 'rss20' +} + +multiple_update_mock = { + 'bozo': 0, + 'encoding': 'utf-8', + 'entries': [ + { + 'guidislink': False, + 'href': '', + 'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '1152', + 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', + 'width': '2048' + }], + 'published': 'Mon, 20 May 2019 16:07:37 GMT', + 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + 'summary': 'Foreign Minister Mohammad Javad Zarif says the US ' + 'president should try showing Iranians some respect.', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': 'Foreign Minister Mohammad Javad ' + 'Zarif says the US president should ' + 'try showing Iranians some ' + 'respect.' + }, + 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': "Trump's 'genocidal taunts' will not " + 'end Iran - Zarif' + } + }, + { + 'guidislink': False, + 'href': '', + 'id': 'https://www.bbc.co.uk/news/technology-48334739', + 'link': 'https://www.bbc.co.uk/news/technology-48334739', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/technology-48334739', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '432', + 'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg', + 'width': '768' + }], + 'published': 'Mon, 20 May 2019 12:19:19 GMT', + 'published_parsed': struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0,)), + 'summary': "Google's move to end business ties with Huawei will " + 'affect current devices and future purchases.', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': "Google's move to end business ties " + 'with Huawei will affect current ' + 'devices and future purchases.' + }, + 'title': "Huawei's Android loss: How it affects you", + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': "Huawei's Android loss: How it " + 'affects you' + } + }, + { + 'guidislink': False, + 'href': '', + 'id': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', + 'link': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '549', + 'url': 'http://c.files.bbci.co.uk/11D67/production/_107036037_lgbtheadjpg.jpg', + 'width': '976' + }], + 'published': 'Mon, 20 May 2019 16:32:38 GMT', + 'published_parsed': struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), + 'summary': 'Police are investigating the messages while an MP ' + 'calls for a protest exclusion zone "to protect ' + 'children".', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': 'Police are investigating the ' + 'messages while an MP calls for a ' + 'protest exclusion zone "to protect ' + 'children".' + }, + 'title': 'Birmingham head teacher threatened over LGBT lessons', + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': 'Birmingham head teacher threatened ' + 'over LGBT lessons' + } + }, + ], + 'feed': { + 'image': { + 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', + 'link': 'https://www.bbc.co.uk/news/', + 'title': 'BBC News - Home', + 'language': 'en-gb', + 'link': 'https://www.bbc.co.uk/news/' + }, + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'title': 'BBC News - Home', + }, + 'href': 'http://feeds.bbci.co.uk/news/rss.xml', + 'status': 200, + 'version': 'rss20' +} diff --git a/src/newsreader/news/collection/tests/feed/collector/tests.py b/src/newsreader/news/collection/tests/feed/collector/tests.py new file mode 100644 index 0000000..db95ccb --- /dev/null +++ b/src/newsreader/news/collection/tests/feed/collector/tests.py @@ -0,0 +1,251 @@ +from datetime import date, datetime, time +from time import struct_time +from unittest.mock import MagicMock, patch + +import pytz + +from freezegun import freeze_time + +from django.test import TestCase +from django.utils import timezone + +from newsreader.news.collection.feed import FeedCollector +from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.feed.collector.mocks import ( + duplicate_mock, + empty_mock, + multiple_mock, + multiple_update_mock, +) +from newsreader.news.collection.utils import build_publication_date +from newsreader.news.posts.models import Post +from newsreader.news.posts.tests.factories import PostFactory + + +class FeedCollectorTestCase(TestCase): + def setUp(self): + self.patched_get = patch( + 'newsreader.news.collection.feed.requests.get' + ) + self.mocked_get = self.patched_get.start() + + self.patched_parse = patch( + 'newsreader.news.collection.feed.FeedStream.parse' + ) + self.mocked_parse = self.patched_parse.start() + + def tearDown(self): + patch.stopall() + + @freeze_time("2019-10-30 12:30:00") + def test_simple_batch(self): + self.mocked_parse.return_value = multiple_mock + rule = CollectionRuleFactory() + + collector = FeedCollector() + collector.collect() + + rule.refresh_from_db() + + self.assertEquals(Post.objects.count(), 3) + self.assertEquals(rule.succeeded, True) + self.assertEquals(rule.last_suceeded, timezone.now()) + self.assertEquals(rule.error, None) + + @freeze_time("2019-10-30 12:30:00") + def test_emtpy_batch(self): + self.mocked_get.return_value = MagicMock(status_code=200) + self.mocked_parse.return_value = empty_mock + rule = CollectionRuleFactory() + + collector = FeedCollector() + collector.collect() + + rule.refresh_from_db() + + self.assertEquals(Post.objects.count(), 0) + self.assertEquals(rule.succeeded, True) + self.assertEquals(rule.error, None) + self.assertEquals(rule.last_suceeded, timezone.now()) + + def test_not_found(self): + self.mocked_get.return_value = MagicMock(status_code=404) + rule = CollectionRuleFactory() + + collector = FeedCollector() + collector.collect() + + rule.refresh_from_db() + + self.assertEquals(Post.objects.count(), 0) + self.assertEquals(rule.succeeded, False) + self.assertEquals(rule.error, "Stream not found") + + def test_denied(self): + self.mocked_get.return_value = MagicMock(status_code=404) + last_suceeded = timezone.make_aware( + datetime.combine(date=date(2019, 10, 30), time=time(12, 30)) + ) + rule = CollectionRuleFactory(last_suceeded=last_suceeded) + + collector = FeedCollector() + collector.collect() + + rule.refresh_from_db() + + self.assertEquals(Post.objects.count(), 0) + self.assertEquals(rule.succeeded, False) + self.assertEquals(rule.error, "Stream not found") + self.assertEquals(rule.last_suceeded, last_suceeded) + + def test_forbidden(self): + self.mocked_get.return_value = MagicMock(status_code=403) + last_suceeded = timezone.make_aware( + datetime.combine(date=date(2019, 10, 30), time=time(12, 30)) + ) + rule = CollectionRuleFactory(last_suceeded=last_suceeded) + + collector = FeedCollector() + collector.collect() + + rule.refresh_from_db() + + self.assertEquals(Post.objects.count(), 0) + self.assertEquals(rule.succeeded, False) + self.assertEquals(rule.error, "Stream forbidden") + self.assertEquals(rule.last_suceeded, last_suceeded) + + def test_timed_out(self): + self.mocked_get.return_value = MagicMock(status_code=408) + last_suceeded = timezone.make_aware( + datetime.combine(date=date(2019, 10, 30), time=time(12, 30)) + ) + rule = CollectionRuleFactory(last_suceeded=last_suceeded) + + collector = FeedCollector() + collector.collect() + + rule.refresh_from_db() + + self.assertEquals(Post.objects.count(), 0) + self.assertEquals(rule.succeeded, False) + self.assertEquals(rule.error, "Stream timed out") + self.assertEquals(rule.last_suceeded, last_suceeded) + + @freeze_time("2019-10-30 12:30:00") + def test_duplicates(self): + self.mocked_parse.return_value = duplicate_mock + rule = CollectionRuleFactory() + + _, aware_datetime = build_publication_date( + struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + pytz.utc + ) + + first_post = PostFactory( + url="https://www.bbc.co.uk/news/world-us-canada-48338168", + title="Trump's 'genocidal taunts' will not end Iran - Zarif", + body="Foreign Minister Mohammad Javad Zarif says the US " + "president should try showing Iranians some respect.", + publication_date=aware_datetime, + rule=rule + ) + + _, aware_datetime = build_publication_date( + struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0,)), + pytz.utc + ) + + second_post = PostFactory( + url="https://www.bbc.co.uk/news/technology-48334739", + title="Huawei's Android loss: How it affects you", + body="Google's move to end business ties with Huawei will " + "affect current devices and future purchases.", + publication_date=aware_datetime, + rule=rule + ) + + _, aware_datetime = build_publication_date( + struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), + pytz.utc + ) + + third_post = PostFactory( + url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + title="Birmingham head teacher threatened over LGBT lessons", + body="Police are investigating the messages while an MP " + "calls for a protest exclusion zone \"to protect " + "children\".", + publication_date=aware_datetime, + rule=rule + ) + + collector = FeedCollector() + collector.collect(rules=[rule]) + + rule.refresh_from_db() + + self.assertEquals(Post.objects.count(), 3) + self.assertEquals(rule.succeeded, True) + self.assertEquals(rule.last_suceeded, timezone.now()) + self.assertEquals(rule.error, None) + + @freeze_time("2019-02-22 12:30:00") + def test_items_with_identifiers_get_updated(self): + self.mocked_parse.return_value = multiple_update_mock + rule = CollectionRuleFactory() + + first_post = PostFactory( + remote_identifier="https://www.bbc.co.uk/news/world-us-canada-48338168", + url="https://www.bbc.co.uk/", + title="Trump", + body="Foreign Minister Mohammad Javad Zarif", + publication_date=timezone.now(), + rule=rule + ) + + second_post = PostFactory( + remote_identifier="https://www.bbc.co.uk/news/technology-48334739", + url="https://www.bbc.co.uk/", + title="Huawei's Android loss: How it affects you", + body="Google's move to end business ties with Huawei will", + publication_date=timezone.now(), + rule=rule + ) + + third_post = PostFactory( + remote_identifier="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + title="Birmingham head teacher threatened over LGBT lessons", + body="Police are investigating the messages while an MP", + publication_date=timezone.now(), + rule=rule + ) + + collector = FeedCollector() + collector.collect(rules=[rule]) + + rule.refresh_from_db() + first_post.refresh_from_db() + second_post.refresh_from_db() + third_post.refresh_from_db() + + self.assertEquals(Post.objects.count(), 3) + self.assertEquals(rule.succeeded, True) + self.assertEquals(rule.last_suceeded, timezone.now()) + self.assertEquals(rule.error, None) + + self.assertEquals( + first_post.title, + "Trump's 'genocidal taunts' will not end Iran - Zarif" + ) + + self.assertEquals( + second_post.title, + "Huawei's Android loss: How it affects you" + ) + + self.assertEquals( + third_post.title, + 'Birmingham head teacher threatened over LGBT lessons' + ) diff --git a/src/newsreader/news/collection/tests/feed/duplicate_handler/__init__.py b/src/newsreader/news/collection/tests/feed/duplicate_handler/__init__.py new file mode 100644 index 0000000..8baa6e5 --- /dev/null +++ b/src/newsreader/news/collection/tests/feed/duplicate_handler/__init__.py @@ -0,0 +1 @@ +from .tests import * diff --git a/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py new file mode 100644 index 0000000..a8600af --- /dev/null +++ b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py @@ -0,0 +1,63 @@ +from django.test import TestCase +from django.utils import timezone + +from newsreader.news.collection.feed import FeedDuplicateHandler +from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.posts.models import Post +from newsreader.news.posts.tests.factories import PostFactory + + +class FeedDuplicateHandlerTestCase(TestCase): + def setUp(self): + pass + + def test_duplicate_entries_with_remote_identifiers(self): + rule = CollectionRuleFactory() + existing_post = PostFactory.create( + remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", rule=rule + ) + new_post = PostFactory.build( + remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", + title="title got updated", + rule=rule + ) + + with FeedDuplicateHandler(rule) as duplicate_handler: + posts_gen = duplicate_handler.check([new_post]) + posts = list(posts_gen) + + post = posts[0] + + self.assertEquals(len(posts), 1) + self.assertEquals(post.publication_date, new_post.publication_date) + self.assertTrue(post.publication_date != existing_post.publication_date) + self.assertTrue(post.title != existing_post.title) + + def test_duplicate_entries_in_recent_database(self): + PostFactory.create_batch(size=20) + + publication_date = timezone.now() + + rule = CollectionRuleFactory() + existing_post = PostFactory.create( + url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + title="Birmingham head teacher threatened over LGBT lessons", + body="Google's move to end business ties with Huawei will affect current devices", + publication_date=publication_date, + remote_identifier=None, + rule=rule + ) + new_post = PostFactory.build( + url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + title="Birmingham head teacher threatened over LGBT lessons", + body="Google's move to end business ties with Huawei will affect current devices", + publication_date=publication_date, + remote_identifier=None, + rule=rule + ) + + with FeedDuplicateHandler(rule) as duplicate_handler: + posts_gen = duplicate_handler.check([new_post]) + posts = list(posts_gen) + + self.assertEquals(len(posts), 0) diff --git a/src/newsreader/news/collection/tests/feed/stream/__init__.py b/src/newsreader/news/collection/tests/feed/stream/__init__.py new file mode 100644 index 0000000..8baa6e5 --- /dev/null +++ b/src/newsreader/news/collection/tests/feed/stream/__init__.py @@ -0,0 +1 @@ +from .tests import * diff --git a/src/newsreader/news/collection/tests/feed/stream/mocks.py b/src/newsreader/news/collection/tests/feed/stream/mocks.py new file mode 100644 index 0000000..5853eb7 --- /dev/null +++ b/src/newsreader/news/collection/tests/feed/stream/mocks.py @@ -0,0 +1,61 @@ +from time import struct_time + +simple_mock = { + 'bozo': 0, + 'encoding': 'utf-8', + 'entries': [{ + 'guidislink': False, + 'href': '', + 'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'media_thumbnail': [{ + 'height': '1152', + 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', + 'width': '2048' + }], + 'published': 'Mon, 20 May 2019 16:07:37 GMT', + 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + 'summary': 'Foreign Minister Mohammad Javad Zarif says the US ' + 'president should try showing Iranians some respect.', + 'summary_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/html', + 'value': 'Foreign Minister Mohammad Javad ' + 'Zarif says the US president should ' + 'try showing Iranians some ' + 'respect.' + }, + 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", + 'title_detail': { + 'base': 'http://feeds.bbci.co.uk/news/rss.xml', + 'language': None, + 'type': 'text/plain', + 'value': "Trump's 'genocidal taunts' will not " + 'end Iran - Zarif' + } + }], + 'feed': { + 'image': { + 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', + 'link': 'https://www.bbc.co.uk/news/', + 'title': 'BBC News - Home', + 'language': 'en-gb', + 'link': 'https://www.bbc.co.uk/news/' + }, + 'links': [{ + 'href': 'https://www.bbc.co.uk/news/', + 'rel': 'alternate', + 'type': 'text/html' + }], + 'title': 'BBC News - Home', + }, + 'href': 'http://feeds.bbci.co.uk/news/rss.xml', + 'status': 200, + 'version': 'rss20' +} diff --git a/src/newsreader/news/collection/tests/feed/stream/tests.py b/src/newsreader/news/collection/tests/feed/stream/tests.py new file mode 100644 index 0000000..6e15194 --- /dev/null +++ b/src/newsreader/news/collection/tests/feed/stream/tests.py @@ -0,0 +1,109 @@ +from unittest.mock import MagicMock, patch + +from django.test import TestCase +from django.utils import timezone + +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamException, + StreamForbiddenException, + StreamNotFoundException, + StreamParseException, + StreamTimeOutException, +) +from newsreader.news.collection.feed import FeedStream +from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.feed.stream.mocks import simple_mock + + +class FeedStreamTestCase(TestCase): + def setUp(self): + self.patched_get = patch( + 'newsreader.news.collection.feed.requests.get' + ) + self.mocked_get = self.patched_get.start() + + self.patched_parse = patch( + 'newsreader.news.collection.feed.FeedStream.parse' + ) + self.mocked_parse = self.patched_parse.start() + + def tearDown(self): + patch.stopall() + + def test_simple_stream(self): + self.mocked_parse.return_value = simple_mock + + rule = CollectionRuleFactory() + stream = FeedStream(rule) + return_value = stream.read() + + self.mocked_get.assert_called_once_with(rule.url) + self.assertEquals(return_value, (simple_mock, stream)) + + def test_stream_raises_exception(self): + self.mocked_parse.side_effect = StreamException + + rule = CollectionRuleFactory() + stream = FeedStream(rule) + + with self.assertRaises(StreamException): + stream.read() + + self.mocked_get.assert_called_once_with(rule.url) + + def test_stream_raises_denied_exception(self): + self.mocked_get.return_value = MagicMock(status_code=401) + + rule = CollectionRuleFactory() + stream = FeedStream(rule) + + with self.assertRaises(StreamDeniedException): + stream.read() + + self.mocked_get.assert_called_once_with(rule.url) + + def test_stream_raises_not_found_exception(self): + self.mocked_get.return_value = MagicMock(status_code=404) + + rule = CollectionRuleFactory() + stream = FeedStream(rule) + + with self.assertRaises(StreamNotFoundException): + stream.read() + + self.mocked_get.assert_called_once_with(rule.url) + + def test_stream_raises_time_out_exception(self): + self.mocked_get.return_value = MagicMock(status_code=408) + + rule = CollectionRuleFactory() + stream = FeedStream(rule) + + with self.assertRaises(StreamTimeOutException): + stream.read() + + self.mocked_get.assert_called_once_with(rule.url) + + def test_stream_raises_forbidden_exception(self): + self.mocked_get.return_value = MagicMock(status_code=403) + + rule = CollectionRuleFactory() + stream = FeedStream(rule) + + with self.assertRaises(StreamForbiddenException): + stream.read() + + self.mocked_get.assert_called_once_with(rule.url) + + @patch("newsreader.news.collection.feed.parse") + def test_stream_raises_parse_exception(self, mocked_parse): + self.mocked_get.return_value = MagicMock(status_code=200) + mocked_parse.side_effect = TypeError + self.patched_parse.stop() + + rule = CollectionRuleFactory() + stream = FeedStream(rule) + + with self.assertRaises(StreamParseException): + stream.read() diff --git a/src/newsreader/news/collection/utils.py b/src/newsreader/news/collection/utils.py new file mode 100644 index 0000000..6a80ed7 --- /dev/null +++ b/src/newsreader/news/collection/utils.py @@ -0,0 +1,13 @@ +from datetime import datetime +from time import mktime + +from django.utils import timezone + + +def build_publication_date(dt, tz): + try: + naive_datetime = datetime.fromtimestamp(mktime(dt)) + published_parsed = timezone.make_aware(naive_datetime, timezone=tz) + except TypeError: + return False, None + return True, published_parsed diff --git a/src/newsreader/news/collection/views.py b/src/newsreader/news/collection/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/src/newsreader/news/collection/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/src/newsreader/news/posts/__init__.py b/src/newsreader/news/posts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/posts/admin.py b/src/newsreader/news/posts/admin.py new file mode 100644 index 0000000..2ba7c81 --- /dev/null +++ b/src/newsreader/news/posts/admin.py @@ -0,0 +1,39 @@ +from django.contrib import admin + +from newsreader.news.posts.models import Category, Post + + +class PostAdmin(admin.ModelAdmin): + list_display = ( + "publication_date", + "author", + "rule", + "title", + ) + list_display_links = ("title", ) + list_filter = ("rule", ) + + ordering = ("-publication_date", "title") + + fields = ( + "title", + "body", + "author", + "publication_date", + "url", + "remote_identifier", + "category", + ) + + search_fields = ["title"] + + def rule(self, obj): + return obj.rule + + +class CategoryAdmin(admin.ModelAdmin): + pass + + +admin.site.register(Post, PostAdmin) +admin.site.register(Category, CategoryAdmin) diff --git a/src/newsreader/news/posts/apps.py b/src/newsreader/news/posts/apps.py new file mode 100644 index 0000000..2c2b982 --- /dev/null +++ b/src/newsreader/news/posts/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class PostsConfig(AppConfig): + name = 'posts' diff --git a/src/newsreader/news/posts/migrations/0001_initial.py b/src/newsreader/news/posts/migrations/0001_initial.py new file mode 100644 index 0000000..36666b3 --- /dev/null +++ b/src/newsreader/news/posts/migrations/0001_initial.py @@ -0,0 +1,78 @@ +# Generated by Django 2.2 on 2019-04-10 20:10 + +import django.db.models.deletion + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('collection', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Category', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID' + ) + ), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=50)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Post', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID' + ) + ), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('title', models.CharField(max_length=200)), + ('body', models.TextField()), + ('source', models.CharField(max_length=200)), + ('publication_date', models.DateTimeField()), + ('url', models.URLField()), + ('remote_identifier', models.CharField(max_length=500)), + ( + 'category', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + to='posts.Category' + ) + ), + ( + 'rule', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='collection.CollectionRule' + ) + ), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/src/newsreader/news/posts/migrations/0002_auto_20190520_2206.py b/src/newsreader/news/posts/migrations/0002_auto_20190520_2206.py new file mode 100644 index 0000000..cb86d51 --- /dev/null +++ b/src/newsreader/news/posts/migrations/0002_auto_20190520_2206.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2 on 2019-05-20 20:06 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('posts', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='category', + options={ + 'verbose_name': 'Category', + 'verbose_name_plural': 'Categories' + }, + ), + ] diff --git a/src/newsreader/news/posts/migrations/0003_auto_20190520_2031.py b/src/newsreader/news/posts/migrations/0003_auto_20190520_2031.py new file mode 100644 index 0000000..a790477 --- /dev/null +++ b/src/newsreader/news/posts/migrations/0003_auto_20190520_2031.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2 on 2019-05-20 20:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('posts', '0002_auto_20190520_2206'), + ] + + operations = [ + migrations.AlterField( + model_name='category', + name='name', + field=models.CharField(max_length=50, unique=True), + ), + ] diff --git a/src/newsreader/news/posts/migrations/0004_auto_20190521_1941.py b/src/newsreader/news/posts/migrations/0004_auto_20190521_1941.py new file mode 100644 index 0000000..a14c636 --- /dev/null +++ b/src/newsreader/news/posts/migrations/0004_auto_20190521_1941.py @@ -0,0 +1,22 @@ +# Generated by Django 2.2 on 2019-05-21 19:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('posts', '0003_auto_20190520_2031'), + ] + + operations = [ + migrations.RemoveField( + model_name='post', + name='source', + ), + migrations.AddField( + model_name='post', + name='author', + field=models.CharField(blank=True, max_length=100, null=True), + ), + ] diff --git a/src/newsreader/news/posts/migrations/0005_auto_20190608_1054.py b/src/newsreader/news/posts/migrations/0005_auto_20190608_1054.py new file mode 100644 index 0000000..96c9d8c --- /dev/null +++ b/src/newsreader/news/posts/migrations/0005_auto_20190608_1054.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2 on 2019-06-08 10:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('posts', '0004_auto_20190521_1941'), + ] + + operations = [ + migrations.AlterField( + model_name='post', + name='body', + field=models.TextField(blank=True), + ), + migrations.AlterField( + model_name='post', + name='remote_identifier', + field=models.CharField(blank=True, max_length=500, null=True), + ), + ] diff --git a/src/newsreader/news/posts/migrations/0006_auto_20190608_1520.py b/src/newsreader/news/posts/migrations/0006_auto_20190608_1520.py new file mode 100644 index 0000000..8215ea9 --- /dev/null +++ b/src/newsreader/news/posts/migrations/0006_auto_20190608_1520.py @@ -0,0 +1,33 @@ +# Generated by Django 2.2 on 2019-06-08 15:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('posts', '0005_auto_20190608_1054'), + ] + + operations = [ + migrations.AlterField( + model_name='post', + name='body', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='post', + name='publication_date', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='post', + name='title', + field=models.CharField(blank=True, max_length=200, null=True), + ), + migrations.AlterField( + model_name='post', + name='url', + field=models.URLField(blank=True, null=True), + ), + ] diff --git a/src/newsreader/news/posts/migrations/__init__.py b/src/newsreader/news/posts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/posts/models.py b/src/newsreader/news/posts/models.py new file mode 100644 index 0000000..0528187 --- /dev/null +++ b/src/newsreader/news/posts/models.py @@ -0,0 +1,34 @@ +from django.db import models +from django.utils.translation import gettext as _ + +from newsreader.core.models import TimeStampedModel +from newsreader.news.collection.models import CollectionRule + + +class Post(TimeStampedModel): + title = models.CharField(max_length=200, blank=True, null=True) + body = models.TextField(blank=True, null=True) + author = models.CharField(max_length=100, blank=True, null=True) + publication_date = models.DateTimeField(blank=True, null=True) + url = models.URLField(blank=True, null=True) + + rule = models.ForeignKey(CollectionRule, on_delete=models.CASCADE) + remote_identifier = models.CharField(max_length=500, blank=True, null=True) + + category = models.ForeignKey( + 'Category', blank=True, null=True, on_delete=models.PROTECT + ) + + def __str__(self): + return "Post-{}".format(self.pk) + + +class Category(TimeStampedModel): + name = models.CharField(max_length=50, unique=True) + + class Meta: + verbose_name = _("Category") + verbose_name_plural = _("Categories") + + def __str__(self): + return self.name diff --git a/src/newsreader/news/posts/tests/__init__.py b/src/newsreader/news/posts/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/posts/tests/factories.py b/src/newsreader/news/posts/tests/factories.py new file mode 100644 index 0000000..d059335 --- /dev/null +++ b/src/newsreader/news/posts/tests/factories.py @@ -0,0 +1,28 @@ +import factory +import pytz + +from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.posts.models import Category, Post + + +class CategoryFactory(factory.django.DjangoModelFactory): + class Meta: + model = Category + + name = factory.Sequence(lambda n: "Category-{}".format(n)) + + +class PostFactory(factory.django.DjangoModelFactory): + class Meta: + model = Post + + title = factory.Faker("sentence") + body = factory.Faker("paragraph") + author = factory.Faker("name") + publication_date = factory.Faker('date_time_this_year', tzinfo=pytz.utc) + url = factory.Faker('url') + remote_identifier = factory.Faker("url") + + rule = factory.SubFactory(CollectionRuleFactory) + + category = factory.SubFactory(CategoryFactory) diff --git a/src/newsreader/news/posts/views.py b/src/newsreader/news/posts/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/src/newsreader/news/posts/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/src/newsreader/urls.py b/src/newsreader/urls.py new file mode 100644 index 0000000..fdd5749 --- /dev/null +++ b/src/newsreader/urls.py @@ -0,0 +1,6 @@ +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), +] diff --git a/src/newsreader/utils/formatter.sh b/src/newsreader/utils/formatter.sh new file mode 100644 index 0000000..de70b4a --- /dev/null +++ b/src/newsreader/utils/formatter.sh @@ -0,0 +1,10 @@ +#!/bin/bash +FILES=$(git diff --cached --name-only --diff-filter=ACM "*.py" | sed 's| |\\ |g') + +if [ ! -z "$FILES" ]; then + # Format all selected files + echo "$FILES" | xargs ./env/bin/isort + + # Add back the modified/prettified files to staging + echo "$FILES" | xargs git add +fi diff --git a/src/newsreader/utils/pre-commit b/src/newsreader/utils/pre-commit new file mode 100644 index 0000000..d1e29b9 --- /dev/null +++ b/src/newsreader/utils/pre-commit @@ -0,0 +1,13 @@ +#!/bin/bash + +# Check if the directory is the root directory +if [ ! -d ".git/" ]; then + echo "Please commit from within the root directory" + exit 1 +fi + +# Run every file inside the pre-commit.d directory +for file in .git/hooks/pre-commit.d/* +do + . $file +done diff --git a/src/newsreader/wsgi.py b/src/newsreader/wsgi.py new file mode 100644 index 0000000..4c5ea26 --- /dev/null +++ b/src/newsreader/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for newsreader project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'newsreader.settings') + +application = get_wsgi_application() From 69e4e7b26941ac6746e2f3bb16608e0eb13c393d Mon Sep 17 00:00:00 2001 From: Sonny Date: Sat, 22 Jun 2019 21:55:47 +0200 Subject: [PATCH 003/422] Update project requirements --- requirements/base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/base.txt b/requirements/base.txt index 0f82f61..68e2d7d 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,9 +1,9 @@ +bleach==3.1.0 certifi==2019.3.9 chardet==3.0.4 Django==2.2 feedparser==5.2.1 idna==2.8 -pkg-resources==0.0.0 pytz==2018.9 requests==2.21.0 sqlparse==0.3.0 From c75de1c469f2a473dd8f558266dd65db38fe5d3a Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 23 Jun 2019 12:52:49 +0200 Subject: [PATCH 004/422] Add type hinting --- src/newsreader/news/collection/base.py | 86 ++++++++++--------- src/newsreader/news/collection/feed.py | 79 ++++++++--------- .../news/collection/response_handler.py | 12 ++- .../collection/tests/feed/client/tests.py | 5 +- src/newsreader/news/collection/utils.py | 7 +- 5 files changed, 92 insertions(+), 97 deletions(-) diff --git a/src/newsreader/news/collection/base.py b/src/newsreader/news/collection/base.py index 58cd9c4..0ab25d3 100644 --- a/src/newsreader/news/collection/base.py +++ b/src/newsreader/news/collection/base.py @@ -1,3 +1,5 @@ +from typing import ContextManager, Dict, List, Optional, Tuple + import requests from django.utils import timezone @@ -5,23 +7,58 @@ from django.utils import timezone from newsreader.news.collection.models import CollectionRule +class Stream: + def __init__(self, rule: CollectionRule) -> None: + self.rule = rule + + def read(self) -> Tuple: + url = self.rule.url + response = requests.get(url) + return (self.parse(response.content), self) + + def parse(self, payload: bytes) -> Dict: + raise NotImplementedError + + class Meta: + abstract = True + + +class Client: + stream = Stream + + def __init__(self, rules: Optional[CollectionRule] = None) -> None: + self.rules = rules if rules else CollectionRule.objects.all() + + def __enter__(self) -> ContextManager: + for rule in self.rules: + stream = self.stream(rule) + + yield stream.read() + + def __exit__(self, *args, **kwargs) -> None: + pass + + class Meta: + abstract = True + + class Builder: instances = [] - def __init__(self, stream): + def __init__(self, stream: Stream) -> None: self.stream = stream - def __enter__(self): + def __enter__(self) -> ContextManager: self.create_posts(self.stream) return self - def __exit__(self, *args, **kwargs): + def __exit__(self, *args, **kwargs) -> None: pass - def create_posts(self, stream): + def create_posts(self, stream: Tuple) -> None: pass - def save(self): + def save(self) -> None: pass class Meta: @@ -32,11 +69,11 @@ class Collector: client = None builder = None - def __init__(self, client=None, builder=None): + def __init__(self, client: Optional[Client] = None, builder: Optional[Builder] = None) -> None: self.client = client if client else self.client self.builder = builder if builder else self.builder - def collect(self, rules=None): + def collect(self, rules: Optional[List] = None) -> None: with self.client(rules=rules) as client: for data, stream in client: with self.builder((data, stream)) as builder: @@ -44,38 +81,3 @@ class Collector: class Meta: abstract = True - - -class Stream: - def __init__(self, rule): - self.rule = rule - - def read(self): - url = self.rule.url - response = requests.get(url) - return (self.parse(response.content), self) - - def parse(self, payload): - raise NotImplementedError - - class Meta: - abstract = True - - -class Client: - stream = Stream - - def __init__(self, rules=None): - self.rules = rules if rules else CollectionRule.objects.all() - - def __enter__(self): - for rule in self.rules: - stream = self.stream(rule) - - yield stream.read() - - def __exit__(self, *args, **kwargs): - pass - - class Meta: - abstract = True diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index fe4e2cf..e2c098f 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -1,4 +1,5 @@ from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import ContextManager, Dict, Generator, List, Optional, Tuple import bleach import pytz @@ -16,6 +17,7 @@ from newsreader.news.collection.exceptions import ( StreamParseException, StreamTimeOutException, ) +from newsreader.news.collection.models import CollectionRule from newsreader.news.collection.response_handler import ResponseHandler from newsreader.news.collection.utils import build_publication_date from newsreader.news.posts.models import Post @@ -24,17 +26,16 @@ from newsreader.news.posts.models import Post class FeedBuilder(Builder): instances = [] - def __enter__(self): + def __enter__(self) -> ContextManager: _, stream = self.stream self.instances = [] self.existing_posts = { - post.remote_identifier: post - for post in Post.objects.filter(rule=stream.rule) + post.remote_identifier: post for post in Post.objects.filter(rule=stream.rule) } return super().__enter__() - def create_posts(self, stream): + def create_posts(self, stream: Tuple) -> None: data, stream = stream entries = [] @@ -49,30 +50,25 @@ class FeedBuilder(Builder): self.instances = [post for post in posts] - def build(self, entries, rule): + def build(self, entries: List, rule: CollectionRule) -> Generator[Post, None, None]: field_mapping = { "id": "remote_identifier", "title": "title", "summary": "body", "link": "url", "published_parsed": "publication_date", - "author": "author" + "author": "author", } tz = pytz.timezone(rule.timezone) for entry in entries: - data = { - "rule_id": rule.pk, - "category": rule.category - } + data = {"rule_id": rule.pk, "category": rule.category} for field, value in field_mapping.items(): if field in entry: if field == "published_parsed": - created, aware_datetime = build_publication_date( - entry[field], tz - ) + created, aware_datetime = build_publication_date(entry[field], tz) data[value] = aware_datetime if created else None elif field == "summary": summary = self.sanitize_summary(entry[field]) @@ -82,19 +78,19 @@ class FeedBuilder(Builder): yield Post(**data) - def sanitize_summary(self, summary): - attrs = {"a": ["href", "rel"], "img": ["alt", "src"],} + def sanitize_summary(self, summary: str) -> Optional[str]: + attrs = {"a": ["href", "rel"], "img": ["alt", "src"]} tags = ["a", "img", "p"] return bleach.clean(summary, tags=tags, attributes=attrs) if summary else None - def save(self): + def save(self) -> None: for post in self.instances: post.save() class FeedStream(Stream): - def read(self): + def read(self) -> Tuple: url = self.rule.url response = requests.get(url) @@ -103,7 +99,7 @@ class FeedStream(Stream): return (self.parse(response.content), self) - def parse(self, payload): + def parse(self, payload: bytes) -> Dict: try: return parse(payload) except TypeError as e: @@ -113,14 +109,11 @@ class FeedStream(Stream): class FeedClient(Client): stream = FeedStream - def __enter__(self): + def __enter__(self) -> ContextManager: streams = [self.stream(rule) for rule in self.rules] with ThreadPoolExecutor(max_workers=10) as executor: - futures = { - executor.submit(stream.read): stream - for stream in streams - } + futures = {executor.submit(stream.read): stream for stream in streams} for future in as_completed(futures): stream = futures[future] @@ -148,20 +141,20 @@ class FeedCollector(Collector): class FeedDuplicateHandler: - def __init__(self, rule): + def __init__(self, rule: CollectionRule) -> None: self.queryset = rule.post_set.all() - def __enter__(self): - self.existing_identifiers = self.queryset.filter(remote_identifier__isnull=False).values_list( - "remote_identifier", flat=True - ) + def __enter__(self) -> ContextManager: + self.existing_identifiers = self.queryset.filter( + remote_identifier__isnull=False + ).values_list("remote_identifier", flat=True) return self - def __exit__(self, *args, **kwargs): + def __exit__(self, *args, **kwargs) -> None: pass - def check(self, instances): - for instance in instances: + def check(self, instances: List) -> Generator[Post, None, None]: + for instance in instances: if instance.remote_identifier in self.existing_identifiers: existing_post = self.handle_duplicate(instance) @@ -173,31 +166,29 @@ class FeedDuplicateHandler: yield instance - def in_database(self, entry): + def in_database(self, post: Post) -> Optional[bool]: values = { - "url": entry.url, - "title": entry.title, - "body": entry.body, - "publication_date": entry.publication_date + "url": post.url, + "title": post.title, + "body": post.body, + "publication_date": post.publication_date, } - for existing_entry in self.queryset.order_by("-publication_date")[:50]: - if self.is_duplicate(existing_entry, values): + for existing_post in self.queryset.order_by("-publication_date")[:50]: + if self.is_duplicate(existing_post, values): return True - def is_duplicate(self, existing_entry, values): + def is_duplicate(self, existing_post: Post, values: Dict) -> bool: for key, value in values.items(): - existing_value = getattr(existing_entry, key, object()) + existing_value = getattr(existing_post, key, object()) if existing_value != value: return False return True - def handle_duplicate(self, instance): + def handle_duplicate(self, instance: Post) -> Optional[Post]: try: - existing_instance = self.queryset.get( - remote_identifier=instance.remote_identifier, - ) + existing_instance = self.queryset.get(remote_identifier=instance.remote_identifier) except ObjectDoesNotExist: return diff --git a/src/newsreader/news/collection/response_handler.py b/src/newsreader/news/collection/response_handler.py index dc33190..ff46171 100644 --- a/src/newsreader/news/collection/response_handler.py +++ b/src/newsreader/news/collection/response_handler.py @@ -1,3 +1,7 @@ +from typing import ContextManager + +from requests import Response + from newsreader.news.collection.exceptions import ( StreamDeniedException, StreamForbiddenException, @@ -14,17 +18,17 @@ class ResponseHandler: 408: StreamTimeOutException, } - def __init__(self, response): + def __init__(self, response: Response) -> None: self.response = response - def __enter__(self): + def __enter__(self) -> ContextManager: return self - def handle_response(self): + def handle_response(self) -> None: status_code = self.response.status_code if status_code in self.message_mapping: raise self.message_mapping[status_code] - def __exit__(self, *args, **kwargs): + def __exit__(self, *args, **kwargs) -> None: self.response = None diff --git a/src/newsreader/news/collection/tests/feed/client/tests.py b/src/newsreader/news/collection/tests/feed/client/tests.py index 0bb4cdd..e236666 100644 --- a/src/newsreader/news/collection/tests/feed/client/tests.py +++ b/src/newsreader/news/collection/tests/feed/client/tests.py @@ -6,7 +6,6 @@ from django.utils import timezone from newsreader.news.collection.exceptions import ( StreamDeniedException, StreamException, - StreamFieldException, StreamNotFoundException, StreamTimeOutException, ) @@ -17,9 +16,7 @@ from newsreader.news.collection.tests.feed.client.mocks import simple_mock class FeedClientTestCase(TestCase): def setUp(self): - self.patched_read = patch( - 'newsreader.news.collection.feed.FeedStream.read' - ) + self.patched_read = patch("newsreader.news.collection.feed.FeedStream.read") self.mocked_read = self.patched_read.start() def tearDown(self): diff --git a/src/newsreader/news/collection/utils.py b/src/newsreader/news/collection/utils.py index 6a80ed7..172fb54 100644 --- a/src/newsreader/news/collection/utils.py +++ b/src/newsreader/news/collection/utils.py @@ -1,10 +1,11 @@ -from datetime import datetime -from time import mktime +from datetime import datetime, tzinfo +from time import mktime, struct_time +from typing import Tuple from django.utils import timezone -def build_publication_date(dt, tz): +def build_publication_date(dt: struct_time, tz: tzinfo) -> Tuple: try: naive_datetime = datetime.fromtimestamp(mktime(dt)) published_parsed = timezone.make_aware(naive_datetime, timezone=tz) From 48a9b25545770db48e318e3250919b134deef53d Mon Sep 17 00:00:00 2001 From: Sonny Date: Mon, 1 Jul 2019 09:36:01 +0200 Subject: [PATCH 005/422] Favicon fetcher --- src/newsreader/news/collection/admin.py | 15 +- src/newsreader/news/collection/base.py | 46 ++++- src/newsreader/news/collection/favicon.py | 113 +++++++++++++ src/newsreader/news/collection/feed.py | 8 +- .../management/commands/fetch_favicons.py | 11 ++ .../migrations/0007_auto_20190623_1837.py | 16 ++ .../migrations/0008_auto_20190623_1847.py | 16 ++ .../0009_collectionrule_website_url.py | 16 ++ .../migrations/0010_auto_20190628_2142.py | 19 +++ src/newsreader/news/collection/models.py | 8 +- .../news/collection/tests/__init__.py | 3 + .../news/collection/tests/factories.py | 1 + .../news/collection/tests/favicon/__init__.py | 3 + .../tests/favicon/builder/__init__.py | 1 + .../collection/tests/favicon/builder/mocks.py | 88 ++++++++++ .../collection/tests/favicon/builder/tests.py | 60 +++++++ .../tests/favicon/client/__init__.py | 1 + .../collection/tests/favicon/client/mocks.py | 12 ++ .../collection/tests/favicon/client/tests.py | 91 ++++++++++ .../tests/favicon/collector/__init__.py | 1 + .../tests/favicon/collector/mocks.py | 159 ++++++++++++++++++ .../tests/favicon/collector/tests.py | 147 ++++++++++++++++ .../collection/tests/feed/builder/tests.py | 105 ++++-------- .../collection/tests/feed/client/tests.py | 19 ++- .../collection/tests/feed/collector/tests.py | 85 +++++----- .../tests/feed/duplicate_handler/tests.py | 8 +- .../collection/tests/feed/stream/mocks.py | 111 ++++++------ .../collection/tests/feed/stream/tests.py | 47 +++--- src/newsreader/news/collection/tests/mocks.py | 47 ++++++ src/newsreader/news/collection/tests/tests.py | 134 +++++++++++++++ .../news/collection/tests/utils/__init__.py | 1 + .../news/collection/tests/utils/tests.py | 57 +++++++ src/newsreader/news/collection/utils.py | 17 +- 33 files changed, 1238 insertions(+), 228 deletions(-) create mode 100644 src/newsreader/news/collection/favicon.py create mode 100644 src/newsreader/news/collection/management/commands/fetch_favicons.py create mode 100644 src/newsreader/news/collection/migrations/0007_auto_20190623_1837.py create mode 100644 src/newsreader/news/collection/migrations/0008_auto_20190623_1847.py create mode 100644 src/newsreader/news/collection/migrations/0009_collectionrule_website_url.py create mode 100644 src/newsreader/news/collection/migrations/0010_auto_20190628_2142.py create mode 100644 src/newsreader/news/collection/tests/favicon/__init__.py create mode 100644 src/newsreader/news/collection/tests/favicon/builder/__init__.py create mode 100644 src/newsreader/news/collection/tests/favicon/builder/mocks.py create mode 100644 src/newsreader/news/collection/tests/favicon/builder/tests.py create mode 100644 src/newsreader/news/collection/tests/favicon/client/__init__.py create mode 100644 src/newsreader/news/collection/tests/favicon/client/mocks.py create mode 100644 src/newsreader/news/collection/tests/favicon/client/tests.py create mode 100644 src/newsreader/news/collection/tests/favicon/collector/__init__.py create mode 100644 src/newsreader/news/collection/tests/favicon/collector/mocks.py create mode 100644 src/newsreader/news/collection/tests/favicon/collector/tests.py create mode 100644 src/newsreader/news/collection/tests/mocks.py create mode 100644 src/newsreader/news/collection/tests/tests.py create mode 100644 src/newsreader/news/collection/tests/utils/__init__.py create mode 100644 src/newsreader/news/collection/tests/utils/tests.py diff --git a/src/newsreader/news/collection/admin.py b/src/newsreader/news/collection/admin.py index 972b020..77ae900 100644 --- a/src/newsreader/news/collection/admin.py +++ b/src/newsreader/news/collection/admin.py @@ -4,20 +4,9 @@ from newsreader.news.collection.models import CollectionRule class CollectionRuleAdmin(admin.ModelAdmin): - fields = ( - "url", - "name", - "timezone", - "category", - ) + fields = ("url", "name", "timezone", "category", "favicon") - list_display = ( - "name", - "category", - "url", - "last_suceeded", - "succeeded", - ) + list_display = ("name", "category", "url", "last_suceeded", "succeeded") admin.site.register(CollectionRule, CollectionRuleAdmin) diff --git a/src/newsreader/news/collection/base.py b/src/newsreader/news/collection/base.py index 0ab25d3..2f392b7 100644 --- a/src/newsreader/news/collection/base.py +++ b/src/newsreader/news/collection/base.py @@ -2,9 +2,13 @@ from typing import ContextManager, Dict, List, Optional, Tuple import requests +from bs4 import BeautifulSoup + from django.utils import timezone +from newsreader.news.collection.exceptions import StreamParseException from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.utils import fetch class Stream: @@ -12,9 +16,7 @@ class Stream: self.rule = rule def read(self) -> Tuple: - url = self.rule.url - response = requests.get(url) - return (self.parse(response.content), self) + raise NotImplementedError def parse(self, payload: bytes) -> Dict: raise NotImplementedError @@ -45,7 +47,7 @@ class Client: class Builder: instances = [] - def __init__(self, stream: Stream) -> None: + def __init__(self, stream: Tuple) -> None: self.stream = stream def __enter__(self) -> ContextManager: @@ -81,3 +83,39 @@ class Collector: class Meta: abstract = True + + +class WebsiteStream(Stream): + def __init__(self, url: str) -> None: + self.url = url + + def read(self) -> Tuple: + response = fetch(self.url) + + return (self.parse(response.content), self) + + def parse(self, payload: bytes) -> BeautifulSoup: + try: + return BeautifulSoup(payload, "lxml") + except TypeError: + raise StreamParseException("Could not parse given HTML") + + +class URLBuilder(Builder): + def __enter__(self) -> ContextManager: + return self + + def build(self) -> Tuple: + data, stream = self.stream + rule = stream.rule + + try: + url = data["feed"]["link"] + except (KeyError, TypeError): + url = None + + if url: + rule.website_url = url + rule.save() + + return rule, url diff --git a/src/newsreader/news/collection/favicon.py b/src/newsreader/news/collection/favicon.py new file mode 100644 index 0000000..8270692 --- /dev/null +++ b/src/newsreader/news/collection/favicon.py @@ -0,0 +1,113 @@ +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import ContextManager, List, Optional +from urllib.parse import urljoin, urlparse + +from newsreader.news.collection.base import ( + Builder, + Client, + Collector, + Stream, + URLBuilder, + WebsiteStream, +) +from newsreader.news.collection.exceptions import StreamException +from newsreader.news.collection.feed import FeedClient + +LINK_RELS = ["icon", "shortcut icon", "apple-touch-icon", "apple-touch-icon-precomposed"] + + +class FaviconBuilder(Builder): + def build(self) -> None: + rule, soup = self.stream + + url = self.parse(soup, rule.website_url) + + if url: + rule.favicon = url + rule.save() + + def parse(self, soup, website_url) -> Optional[str]: + if not soup.head: + return + + links = soup.head.find_all("link") + url = self.parse_links(links) + + if not url: + return + + parsed_url = urlparse(url) + + if not parsed_url.scheme and not parsed_url.netloc: + if not website_url: + return + return urljoin(website_url, url) + elif not parsed_url.scheme: + return urljoin(f"https://{parsed_url.netloc}", parsed_url.path) + + return url + + def parse_links(self, links: List) -> Optional[str]: + favicons = set() + icons = set() + + for link in links: + if not "href" in link.attrs: + continue + + if "favicon" in link["href"]: + favicons.add(link["href"].lower()) + + if "rel" in link.attrs: + for rel in link["rel"]: + if rel in LINK_RELS: + icons.add(link["href"].lower()) + + if favicons: + return favicons.pop() + elif icons: + return icons.pop() + + +class FaviconClient(Client): + stream = WebsiteStream + + def __init__(self, streams: List) -> None: + self.streams = streams + + def __enter__(self) -> ContextManager: + with ThreadPoolExecutor(max_workers=10) as executor: + futures = {executor.submit(stream.read): rule for rule, stream in self.streams} + + for future in as_completed(futures): + rule = futures[future] + + try: + response_data, stream = future.result() + except StreamException: + continue + + yield (rule, response_data) + + +class FaviconCollector(Collector): + feed_client, favicon_client = (FeedClient, FaviconClient) + url_builder, favicon_builder = (URLBuilder, FaviconBuilder) + + def collect(self, rules: Optional[List] = None) -> None: + streams = [] + + with self.feed_client(rules=rules) as client: + for data, stream in client: + with self.url_builder((data, stream)) as builder: + rule, url = builder.build() + + if not url: + continue + + streams.append((rule, WebsiteStream(url))) + + with self.favicon_client(streams) as client: + for rule, data in client: + with self.favicon_builder((rule, data)) as builder: + builder.build() diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index e2c098f..ef66b69 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -3,7 +3,6 @@ from typing import ContextManager, Dict, Generator, List, Optional, Tuple import bleach import pytz -import requests from feedparser import parse @@ -19,7 +18,7 @@ from newsreader.news.collection.exceptions import ( ) from newsreader.news.collection.models import CollectionRule from newsreader.news.collection.response_handler import ResponseHandler -from newsreader.news.collection.utils import build_publication_date +from newsreader.news.collection.utils import build_publication_date, fetch from newsreader.news.posts.models import Post @@ -92,10 +91,7 @@ class FeedBuilder(Builder): class FeedStream(Stream): def read(self) -> Tuple: url = self.rule.url - response = requests.get(url) - - with ResponseHandler(response) as response_handler: - response_handler.handle_response() + response = fetch(url) return (self.parse(response.content), self) diff --git a/src/newsreader/news/collection/management/commands/fetch_favicons.py b/src/newsreader/news/collection/management/commands/fetch_favicons.py new file mode 100644 index 0000000..1ee96cf --- /dev/null +++ b/src/newsreader/news/collection/management/commands/fetch_favicons.py @@ -0,0 +1,11 @@ +from django.core.management.base import BaseCommand + +from newsreader.news.collection.favicon import FaviconCollector + + +class Command(BaseCommand): + help = "Fetch favicons for collection rules" + + def handle(self, *args, **options): + collector = FaviconCollector() + collector.collect() diff --git a/src/newsreader/news/collection/migrations/0007_auto_20190623_1837.py b/src/newsreader/news/collection/migrations/0007_auto_20190623_1837.py new file mode 100644 index 0000000..cc27d6e --- /dev/null +++ b/src/newsreader/news/collection/migrations/0007_auto_20190623_1837.py @@ -0,0 +1,16 @@ +# Generated by Django 2.2 on 2019-06-23 18:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("collection", "0006_collectionrule_error")] + + operations = [ + migrations.AlterField( + model_name="collectionrule", + name="favicon", + field=models.ImageField(default="favicons/default-favicon.ico", upload_to="favicons/"), + ) + ] diff --git a/src/newsreader/news/collection/migrations/0008_auto_20190623_1847.py b/src/newsreader/news/collection/migrations/0008_auto_20190623_1847.py new file mode 100644 index 0000000..3c8ae66 --- /dev/null +++ b/src/newsreader/news/collection/migrations/0008_auto_20190623_1847.py @@ -0,0 +1,16 @@ +# Generated by Django 2.2 on 2019-06-23 18:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("collection", "0007_auto_20190623_1837")] + + operations = [ + migrations.AlterField( + model_name="collectionrule", + name="favicon", + field=models.URLField(blank=True, null=True), + ) + ] diff --git a/src/newsreader/news/collection/migrations/0009_collectionrule_website_url.py b/src/newsreader/news/collection/migrations/0009_collectionrule_website_url.py new file mode 100644 index 0000000..e5273b3 --- /dev/null +++ b/src/newsreader/news/collection/migrations/0009_collectionrule_website_url.py @@ -0,0 +1,16 @@ +# Generated by Django 2.2 on 2019-06-27 21:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("collection", "0008_auto_20190623_1847")] + + operations = [ + migrations.AddField( + model_name="collectionrule", + name="website_url", + field=models.URLField(blank=True, editable=False, null=True), + ) + ] diff --git a/src/newsreader/news/collection/migrations/0010_auto_20190628_2142.py b/src/newsreader/news/collection/migrations/0010_auto_20190628_2142.py new file mode 100644 index 0000000..3726158 --- /dev/null +++ b/src/newsreader/news/collection/migrations/0010_auto_20190628_2142.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2 on 2019-06-28 21:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("collection", "0009_collectionrule_website_url")] + + operations = [ + migrations.AlterField( + model_name="collectionrule", name="url", field=models.URLField(max_length=1024) + ), + migrations.AlterField( + model_name="collectionrule", + name="website_url", + field=models.URLField(blank=True, editable=False, max_length=1024, null=True), + ), + ] diff --git a/src/newsreader/news/collection/models.py b/src/newsreader/news/collection/models.py index ffb4131..1de9849 100644 --- a/src/newsreader/news/collection/models.py +++ b/src/newsreader/news/collection/models.py @@ -1,5 +1,6 @@ import pytz +from django.conf import settings from django.db import models from django.utils.translation import gettext as _ @@ -8,8 +9,9 @@ class CollectionRule(models.Model): name = models.CharField(max_length=100) source = models.CharField(max_length=100) - url = models.URLField() - favicon = models.ImageField(blank=True, null=True) + url = models.URLField(max_length=1024) + website_url = models.URLField(max_length=1024, editable=False, blank=True, null=True) + favicon = models.URLField(blank=True, null=True) timezone = models.CharField( choices=((timezone, timezone) for timezone in pytz.all_timezones), @@ -23,7 +25,7 @@ class CollectionRule(models.Model): null=True, verbose_name=_("Category"), help_text=_("Posts from this rule will be tagged with this category"), - on_delete=models.SET_NULL + on_delete=models.SET_NULL, ) last_suceeded = models.DateTimeField(blank=True, null=True) diff --git a/src/newsreader/news/collection/tests/__init__.py b/src/newsreader/news/collection/tests/__init__.py index fb6723f..ea6a7c0 100644 --- a/src/newsreader/news/collection/tests/__init__.py +++ b/src/newsreader/news/collection/tests/__init__.py @@ -1 +1,4 @@ +from .favicon import * from .feed import * +from .tests import * +from .utils import * diff --git a/src/newsreader/news/collection/tests/factories.py b/src/newsreader/news/collection/tests/factories.py index 6b42292..be54806 100644 --- a/src/newsreader/news/collection/tests/factories.py +++ b/src/newsreader/news/collection/tests/factories.py @@ -10,3 +10,4 @@ class CollectionRuleFactory(factory.django.DjangoModelFactory): name = factory.Sequence(lambda n: "CollectionRule-{}".format(n)) source = factory.Faker("name") url = factory.Faker("url") + website_url = factory.Faker("url") diff --git a/src/newsreader/news/collection/tests/favicon/__init__.py b/src/newsreader/news/collection/tests/favicon/__init__.py new file mode 100644 index 0000000..5fb0299 --- /dev/null +++ b/src/newsreader/news/collection/tests/favicon/__init__.py @@ -0,0 +1,3 @@ +from .builder import * +from .client import * +from .collector import * diff --git a/src/newsreader/news/collection/tests/favicon/builder/__init__.py b/src/newsreader/news/collection/tests/favicon/builder/__init__.py new file mode 100644 index 0000000..8baa6e5 --- /dev/null +++ b/src/newsreader/news/collection/tests/favicon/builder/__init__.py @@ -0,0 +1 @@ +from .tests import * diff --git a/src/newsreader/news/collection/tests/favicon/builder/mocks.py b/src/newsreader/news/collection/tests/favicon/builder/mocks.py new file mode 100644 index 0000000..ce02475 --- /dev/null +++ b/src/newsreader/news/collection/tests/favicon/builder/mocks.py @@ -0,0 +1,88 @@ +from bs4 import BeautifulSoup + +simple_mock = BeautifulSoup( + """ + + + + + +

+ + + """, + "lxml", +) + +mock_without_url = BeautifulSoup( + """ + + + + + +
+ + + """, + "lxml", +) + +mock_without_header = BeautifulSoup( + """ + + +
+ + + """, + "lxml", +) + +mock_with_weird_path = BeautifulSoup( + """ + + + + + +
+ + + """, + "lxml", +) + +mock_with_other_url = BeautifulSoup( + """ + + + + + + +
+ + + """, + "lxml", +) + +mock_with_multiple_icons = BeautifulSoup( + """ + + + + + + + + + + +
+ + + """, + "lxml", +) diff --git a/src/newsreader/news/collection/tests/favicon/builder/tests.py b/src/newsreader/news/collection/tests/favicon/builder/tests.py new file mode 100644 index 0000000..d08fce7 --- /dev/null +++ b/src/newsreader/news/collection/tests/favicon/builder/tests.py @@ -0,0 +1,60 @@ +from freezegun import freeze_time + +from django.test import TestCase + +from newsreader.news.collection.favicon import FaviconBuilder +from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.favicon.builder.mocks import * + + +class FaviconBuilderTestCase(TestCase): + def setUp(self): + self.maxDiff = None + + def test_simple(self): + rule = CollectionRuleFactory(favicon=None) + + with FaviconBuilder((rule, simple_mock)) as builder: + builder.build() + + self.assertEquals(rule.favicon, "https://www.bbc.com/favicon.ico") + + def test_without_url(self): + rule = CollectionRuleFactory(website_url="https://www.theguardian.com/", favicon=None) + + with FaviconBuilder((rule, mock_without_url)) as builder: + builder.build() + + self.assertEquals(rule.favicon, "https://www.theguardian.com/favicon.ico") + + def test_without_header(self): + rule = CollectionRuleFactory(favicon=None) + + with FaviconBuilder((rule, mock_without_header)) as builder: + builder.build() + + self.assertEquals(rule.favicon, None) + + def test_weird_path(self): + rule = CollectionRuleFactory(favicon=None) + + with FaviconBuilder((rule, mock_with_weird_path)) as builder: + builder.build() + + self.assertEquals(rule.favicon, "https://www.theguardian.com/jabadaba/doe/favicon.ico") + + def test_other_url(self): + rule = CollectionRuleFactory(favicon=None) + + with FaviconBuilder((rule, mock_with_other_url)) as builder: + builder.build() + + self.assertEquals(rule.favicon, "https://www.theguardian.com/icon.png") + + def test_url_with_favicon_takes_precedence(self): + rule = CollectionRuleFactory(favicon=None) + + with FaviconBuilder((rule, mock_with_multiple_icons)) as builder: + builder.build() + + self.assertEquals(rule.favicon, "https://www.bbc.com/favicon.ico") diff --git a/src/newsreader/news/collection/tests/favicon/client/__init__.py b/src/newsreader/news/collection/tests/favicon/client/__init__.py new file mode 100644 index 0000000..8baa6e5 --- /dev/null +++ b/src/newsreader/news/collection/tests/favicon/client/__init__.py @@ -0,0 +1 @@ +from .tests import * diff --git a/src/newsreader/news/collection/tests/favicon/client/mocks.py b/src/newsreader/news/collection/tests/favicon/client/mocks.py new file mode 100644 index 0000000..ba79b27 --- /dev/null +++ b/src/newsreader/news/collection/tests/favicon/client/mocks.py @@ -0,0 +1,12 @@ +from bs4 import BeautifulSoup + +simple_mock = BeautifulSoup( + """ + + +
+ + + """, + "lxml", +) diff --git a/src/newsreader/news/collection/tests/favicon/client/tests.py b/src/newsreader/news/collection/tests/favicon/client/tests.py new file mode 100644 index 0000000..4ac2a40 --- /dev/null +++ b/src/newsreader/news/collection/tests/favicon/client/tests.py @@ -0,0 +1,91 @@ +from unittest.mock import MagicMock + +from django.test import TestCase + +from newsreader.news.collection.base import WebsiteStream +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamException, + StreamNotFoundException, + StreamTimeOutException, +) +from newsreader.news.collection.favicon import FaviconClient +from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.favicon.client.mocks import simple_mock + + +class FaviconClientTestCase(TestCase): + def setUp(self): + self.maxDiff = None + + def test_simple(self): + rule = CollectionRuleFactory() + stream = MagicMock(url="https://www.bbc.com") + stream.read.return_value = (simple_mock, stream) + + with FaviconClient([(rule, stream)]) as client: + for rule, data in client: + self.assertEquals(rule.pk, rule.pk) + self.assertEquals(data, simple_mock) + + stream.read.assert_called_once_with() + + def test_client_catches_stream_exception(self): + rule = CollectionRuleFactory(error=None, succeeded=True) + stream = MagicMock(url="https://www.bbc.com") + stream.read.side_effect = StreamException + + with FaviconClient([(rule, stream)]) as client: + for rule, data in client: + pass + + stream.read.assert_called_once_with() + + # The favicon client does not set CollectionRule errors + self.assertEquals(rule.succeeded, True) + self.assertEquals(rule.error, None) + + def test_client_catches_stream_not_found_exception(self): + rule = CollectionRuleFactory(error=None, succeeded=True) + stream = MagicMock(url="https://www.bbc.com") + stream.read.side_effect = StreamNotFoundException + + with FaviconClient([(rule, stream)]) as client: + for rule, data in client: + pass + + stream.read.assert_called_once_with() + + # The favicon client does not set CollectionRule errors + self.assertEquals(rule.succeeded, True) + self.assertEquals(rule.error, None) + + def test_client_catches_stream_denied_exception(self): + rule = CollectionRuleFactory(error=None, succeeded=True) + stream = MagicMock(url="https://www.bbc.com") + stream.read.side_effect = StreamDeniedException + + with FaviconClient([(rule, stream)]) as client: + for rule, data in client: + pass + + stream.read.assert_called_once_with() + + # The favicon client does not set CollectionRule errors + self.assertEquals(rule.succeeded, True) + self.assertEquals(rule.error, None) + + def test_client_catches_stream_timed_out(self): + rule = CollectionRuleFactory(error=None, succeeded=True) + stream = MagicMock(url="https://www.bbc.com") + stream.read.side_effect = StreamTimeOutException + + with FaviconClient([(rule, stream)]) as client: + for rule, data in client: + pass + + stream.read.assert_called_once_with() + + # The favicon client does not set CollectionRule errors + self.assertEquals(rule.succeeded, True) + self.assertEquals(rule.error, None) diff --git a/src/newsreader/news/collection/tests/favicon/collector/__init__.py b/src/newsreader/news/collection/tests/favicon/collector/__init__.py new file mode 100644 index 0000000..8baa6e5 --- /dev/null +++ b/src/newsreader/news/collection/tests/favicon/collector/__init__.py @@ -0,0 +1 @@ +from .tests import * diff --git a/src/newsreader/news/collection/tests/favicon/collector/mocks.py b/src/newsreader/news/collection/tests/favicon/collector/mocks.py new file mode 100644 index 0000000..8e58167 --- /dev/null +++ b/src/newsreader/news/collection/tests/favicon/collector/mocks.py @@ -0,0 +1,159 @@ +from time import struct_time + +from bs4 import BeautifulSoup + +feed_mock = { + "bozo": 0, + "encoding": "utf-8", + "entries": [ + { + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "link": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "links": [ + { + "href": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "1152", + "url": "http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg", + "width": "2048", + } + ], + "published": "Mon, 20 May 2019 16:07:37 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + "summary": "Foreign Minister Mohammad Javad Zarif says the US " + "president should try showing Iranians some respect.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Foreign Minister Mohammad Javad " + "Zarif says the US president should " + "try showing Iranians some " + "respect.", + }, + "title": "Trump's genocidal taunts will not end Iran - Zarif", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Trump's genocidal taunts will not " "end Iran - Zarif", + }, + }, + { + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/technology-48334739", + "link": "https://www.bbc.co.uk/news/technology-48334739", + "links": [ + { + "href": "https://www.bbc.co.uk/news/technology-48334739", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "432", + "url": "http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg", + "width": "768", + } + ], + "published": "Mon, 20 May 2019 12:19:19 GMT", + "published_parsed": struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0)), + "summary": "Google's move to end business ties with Huawei will " + "affect current devices and future purchases.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Google's move to end business ties " + "with Huawei will affect current " + "devices and future purchases.", + }, + "title": "Huawei's Android loss: How it affects you", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Huawei's Android loss: How it " "affects you", + }, + }, + { + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + "link": "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + "links": [ + { + "href": "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "549", + "url": "http://c.files.bbci.co.uk/11D67/production/_107036037_lgbtheadjpg.jpg", + "width": "976", + } + ], + "published": "Mon, 20 May 2019 16:32:38 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), + "summary": "Police are investigating the messages while an MP " + "calls for a protest exclusion zone to protect " + "children.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Police are investigating the " + "messages while an MP calls for a " + "protest exclusion zone to protect " + "children.", + }, + "title": "Birmingham head teacher threatened over LGBT lessons", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Birmingham head teacher threatened " "over LGBT lessons", + }, + }, + ], + "feed": { + "image": { + "href": "https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif", + "link": "https://www.bbc.co.uk/news/", + "title": "BBC News - Home", + "language": "en-gb", + "link": "https://www.bbc.co.uk/news/", + }, + "link": "https://www.bbc.co.uk/news/", + "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "title": "BBC News - Home", + }, + "href": "http://feeds.bbci.co.uk/news/rss.xml", + "status": 200, + "version": "rss20", +} + +website_mock = BeautifulSoup( + """ + + + + + +
+ + + """, + "lxml", +) diff --git a/src/newsreader/news/collection/tests/favicon/collector/tests.py b/src/newsreader/news/collection/tests/favicon/collector/tests.py new file mode 100644 index 0000000..6554de4 --- /dev/null +++ b/src/newsreader/news/collection/tests/favicon/collector/tests.py @@ -0,0 +1,147 @@ +from unittest.mock import MagicMock, patch + +import pytz + +from bs4 import BeautifulSoup + +from .mocks import feed_mock, website_mock + +from django.test import TestCase +from django.utils import timezone + +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamException, + StreamForbiddenException, + StreamNotFoundException, + StreamParseException, + StreamTimeOutException, +) +from newsreader.news.collection.favicon import FaviconCollector +from newsreader.news.collection.tests.factories import CollectionRuleFactory + + +class FaviconCollectorTestCase(TestCase): + def setUp(self): + self.maxDiff = None + + self.patched_feed_client = patch("newsreader.news.collection.favicon.FeedClient.__enter__") + self.mocked_feed_client = self.patched_feed_client.start() + + self.patched_website_read = patch("newsreader.news.collection.favicon.WebsiteStream.read") + self.mocked_website_read = self.patched_website_read.start() + + def tearDown(self): + patch.stopall() + + def test_simple(self): + rule = CollectionRuleFactory(succeeded=True, error=None) + + self.mocked_feed_client.return_value = [(feed_mock, MagicMock(rule=rule))] + self.mocked_website_read.return_value = (website_mock, MagicMock()) + + collector = FaviconCollector() + collector.collect() + + rule.refresh_from_db() + + self.assertEquals(rule.succeeded, True) + self.assertEquals(rule.error, None) + self.assertEquals(rule.website_url, "https://www.bbc.co.uk/news/") + self.assertEquals(rule.favicon, "https://www.bbc.co.uk/news/favicon.ico") + + def test_empty_stream(self): + rule = CollectionRuleFactory(succeeded=True, error=None) + + self.mocked_feed_client.return_value = [(feed_mock, MagicMock(rule=rule))] + self.mocked_website_read.return_value = (BeautifulSoup("", "lxml"), MagicMock()) + + collector = FaviconCollector() + collector.collect() + + rule.refresh_from_db() + + self.assertEquals(rule.succeeded, True) + self.assertEquals(rule.error, None) + self.assertEquals(rule.website_url, "https://www.bbc.co.uk/news/") + self.assertEquals(rule.favicon, None) + + def test_not_found(self): + rule = CollectionRuleFactory(succeeded=True, error=None) + + self.mocked_feed_client.return_value = [(feed_mock, MagicMock(rule=rule))] + self.mocked_website_read.side_effect = StreamNotFoundException + + collector = FaviconCollector() + collector.collect() + + rule.refresh_from_db() + + self.assertEquals(rule.succeeded, True) + self.assertEquals(rule.error, None) + self.assertEquals(rule.website_url, "https://www.bbc.co.uk/news/") + self.assertEquals(rule.favicon, None) + + def test_denied(self): + rule = CollectionRuleFactory(succeeded=True, error=None) + + self.mocked_feed_client.return_value = [(feed_mock, MagicMock(rule=rule))] + self.mocked_website_read.side_effect = StreamDeniedException + + collector = FaviconCollector() + collector.collect() + + rule.refresh_from_db() + + self.assertEquals(rule.succeeded, True) + self.assertEquals(rule.error, None) + self.assertEquals(rule.website_url, "https://www.bbc.co.uk/news/") + self.assertEquals(rule.favicon, None) + + def test_forbidden(self): + rule = CollectionRuleFactory(succeeded=True, error=None) + + self.mocked_feed_client.return_value = [(feed_mock, MagicMock(rule=rule))] + self.mocked_website_read.side_effect = StreamForbiddenException + + collector = FaviconCollector() + collector.collect() + + rule.refresh_from_db() + + self.assertEquals(rule.succeeded, True) + self.assertEquals(rule.error, None) + self.assertEquals(rule.website_url, "https://www.bbc.co.uk/news/") + self.assertEquals(rule.favicon, None) + + def test_timed_out(self): + rule = CollectionRuleFactory(succeeded=True, error=None) + + self.mocked_feed_client.return_value = [(feed_mock, MagicMock(rule=rule))] + self.mocked_website_read.side_effect = StreamTimeOutException + + collector = FaviconCollector() + collector.collect() + + rule.refresh_from_db() + + self.assertEquals(rule.succeeded, True) + self.assertEquals(rule.error, None) + self.assertEquals(rule.website_url, "https://www.bbc.co.uk/news/") + self.assertEquals(rule.favicon, None) + + def test_wrong_stream_content_type(self): + rule = CollectionRuleFactory(succeeded=True, error=None) + + self.mocked_feed_client.return_value = [(feed_mock, MagicMock(rule=rule))] + self.mocked_website_read.side_effect = StreamParseException + + collector = FaviconCollector() + collector.collect() + + rule.refresh_from_db() + + self.assertEquals(rule.succeeded, True) + self.assertEquals(rule.error, None) + self.assertEquals(rule.website_url, "https://www.bbc.co.uk/news/") + self.assertEquals(rule.favicon, None) diff --git a/src/newsreader/news/collection/tests/feed/builder/tests.py b/src/newsreader/news/collection/tests/feed/builder/tests.py index 6e4e44d..49a130a 100644 --- a/src/newsreader/news/collection/tests/feed/builder/tests.py +++ b/src/newsreader/news/collection/tests/feed/builder/tests.py @@ -5,26 +5,27 @@ import pytz from freezegun import freeze_time +from .mocks import * + from django.test import TestCase from django.utils import timezone from newsreader.news.collection.feed import FeedBuilder from newsreader.news.collection.tests.factories import CollectionRuleFactory -from newsreader.news.collection.tests.feed.builder.mocks import * from newsreader.news.posts.models import Post from newsreader.news.posts.tests.factories import PostFactory class FeedBuilderTestCase(TestCase): def setUp(self): - pass + self.maxDiff = None def test_basic_entry(self): builder = FeedBuilder rule = CollectionRuleFactory() mock_stream = MagicMock(rule=rule) - with builder((simple_mock, mock_stream,)) as builder: + with builder((simple_mock, mock_stream)) as builder: builder.save() post = Post.objects.get() @@ -36,26 +37,19 @@ class FeedBuilderTestCase(TestCase): self.assertEquals(Post.objects.count(), 1) self.assertEquals( - post.remote_identifier, - "https://www.bbc.co.uk/news/world-us-canada-48338168" + post.remote_identifier, "https://www.bbc.co.uk/news/world-us-canada-48338168" ) - self.assertEquals( - post.url, - "https://www.bbc.co.uk/news/world-us-canada-48338168" - ) + self.assertEquals(post.url, "https://www.bbc.co.uk/news/world-us-canada-48338168") - self.assertEquals( - post.title, - "Trump's 'genocidal taunts' will not end Iran - Zarif" - ) + self.assertEquals(post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif") def test_multiple_entries(self): builder = FeedBuilder rule = CollectionRuleFactory() mock_stream = MagicMock(rule=rule) - with builder((multiple_mock, mock_stream,)) as builder: + with builder((multiple_mock, mock_stream)) as builder: builder.save() posts = Post.objects.order_by("id") @@ -70,19 +64,12 @@ class FeedBuilderTestCase(TestCase): self.assertEquals(first_post.publication_date, aware_date) self.assertEquals( - first_post.remote_identifier, - "https://www.bbc.co.uk/news/world-us-canada-48338168" + first_post.remote_identifier, "https://www.bbc.co.uk/news/world-us-canada-48338168" ) - self.assertEquals( - first_post.url, - "https://www.bbc.co.uk/news/world-us-canada-48338168" - ) + self.assertEquals(first_post.url, "https://www.bbc.co.uk/news/world-us-canada-48338168") - self.assertEquals( - first_post.title, - "Trump's 'genocidal taunts' will not end Iran - Zarif" - ) + self.assertEquals(first_post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif") d = datetime.combine(date(2019, 5, 20), time(hour=12, minute=19, second=19)) aware_date = pytz.utc.localize(d) @@ -90,26 +77,19 @@ class FeedBuilderTestCase(TestCase): self.assertEquals(second_post.publication_date, aware_date) self.assertEquals( - second_post.remote_identifier, - "https://www.bbc.co.uk/news/technology-48334739" + second_post.remote_identifier, "https://www.bbc.co.uk/news/technology-48334739" ) - self.assertEquals( - second_post.url, - "https://www.bbc.co.uk/news/technology-48334739" - ) + self.assertEquals(second_post.url, "https://www.bbc.co.uk/news/technology-48334739") - self.assertEquals( - second_post.title, - "Huawei's Android loss: How it affects you" - ) + self.assertEquals(second_post.title, "Huawei's Android loss: How it affects you") def test_entry_without_remote_identifier(self): builder = FeedBuilder rule = CollectionRuleFactory() mock_stream = MagicMock(rule=rule) - with builder((mock_without_identifier, mock_stream,)) as builder: + with builder((mock_without_identifier, mock_stream)) as builder: builder.save() posts = Post.objects.order_by("id") @@ -124,15 +104,9 @@ class FeedBuilderTestCase(TestCase): self.assertEquals(first_post.remote_identifier, None) - self.assertEquals( - first_post.url, - "https://www.bbc.co.uk/news/world-us-canada-48338168" - ) + self.assertEquals(first_post.url, "https://www.bbc.co.uk/news/world-us-canada-48338168") - self.assertEquals( - first_post.title, - "Trump's 'genocidal taunts' will not end Iran - Zarif" - ) + self.assertEquals(first_post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif") @freeze_time("2019-10-30 12:30:00") def test_entry_without_publication_date(self): @@ -140,7 +114,7 @@ class FeedBuilderTestCase(TestCase): rule = CollectionRuleFactory() mock_stream = MagicMock(rule=rule) - with builder((mock_without_publish_date, mock_stream,)) as builder: + with builder((mock_without_publish_date, mock_stream)) as builder: builder.save() posts = Post.objects.order_by("id") @@ -151,14 +125,12 @@ class FeedBuilderTestCase(TestCase): self.assertEquals(first_post.created, timezone.now()) self.assertEquals( - first_post.remote_identifier, - 'https://www.bbc.co.uk/news/world-us-canada-48338168' + first_post.remote_identifier, "https://www.bbc.co.uk/news/world-us-canada-48338168" ) self.assertEquals(second_post.created, timezone.now()) self.assertEquals( - second_post.remote_identifier, - 'https://www.bbc.co.uk/news/technology-48334739' + second_post.remote_identifier, "https://www.bbc.co.uk/news/technology-48334739" ) @freeze_time("2019-10-30 12:30:00") @@ -167,7 +139,7 @@ class FeedBuilderTestCase(TestCase): rule = CollectionRuleFactory() mock_stream = MagicMock(rule=rule) - with builder((mock_without_url, mock_stream,)) as builder: + with builder((mock_without_url, mock_stream)) as builder: builder.save() posts = Post.objects.order_by("id") @@ -178,14 +150,12 @@ class FeedBuilderTestCase(TestCase): self.assertEquals(first_post.created, timezone.now()) self.assertEquals( - first_post.remote_identifier, - 'https://www.bbc.co.uk/news/world-us-canada-48338168' + first_post.remote_identifier, "https://www.bbc.co.uk/news/world-us-canada-48338168" ) self.assertEquals(second_post.created, timezone.now()) self.assertEquals( - second_post.remote_identifier, - 'https://www.bbc.co.uk/news/technology-48334739' + second_post.remote_identifier, "https://www.bbc.co.uk/news/technology-48334739" ) @freeze_time("2019-10-30 12:30:00") @@ -194,7 +164,7 @@ class FeedBuilderTestCase(TestCase): rule = CollectionRuleFactory() mock_stream = MagicMock(rule=rule) - with builder((mock_without_body, mock_stream,)) as builder: + with builder((mock_without_body, mock_stream)) as builder: builder.save() posts = Post.objects.order_by("id") @@ -205,14 +175,13 @@ class FeedBuilderTestCase(TestCase): self.assertEquals(first_post.created, timezone.now()) self.assertEquals( - first_post.remote_identifier, - 'https://www.bbc.co.uk/news/world-us-canada-48338168' + first_post.remote_identifier, "https://www.bbc.co.uk/news/world-us-canada-48338168" ) self.assertEquals(second_post.created, timezone.now()) self.assertEquals( second_post.remote_identifier, - 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080' + "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", ) @freeze_time("2019-10-30 12:30:00") @@ -221,7 +190,7 @@ class FeedBuilderTestCase(TestCase): rule = CollectionRuleFactory() mock_stream = MagicMock(rule=rule) - with builder((mock_without_author, mock_stream,)) as builder: + with builder((mock_without_author, mock_stream)) as builder: builder.save() posts = Post.objects.order_by("id") @@ -232,14 +201,12 @@ class FeedBuilderTestCase(TestCase): self.assertEquals(first_post.created, timezone.now()) self.assertEquals( - first_post.remote_identifier, - 'https://www.bbc.co.uk/news/world-us-canada-48338168' + first_post.remote_identifier, "https://www.bbc.co.uk/news/world-us-canada-48338168" ) self.assertEquals(second_post.created, timezone.now()) self.assertEquals( - second_post.remote_identifier, - 'https://www.bbc.co.uk/news/technology-48334739' + second_post.remote_identifier, "https://www.bbc.co.uk/news/technology-48334739" ) def test_empty_entries(self): @@ -247,7 +214,7 @@ class FeedBuilderTestCase(TestCase): rule = CollectionRuleFactory() mock_stream = MagicMock(rule=rule) - with builder((mock_without_entries, mock_stream,)) as builder: + with builder((mock_without_entries, mock_stream)) as builder: builder.save() self.assertEquals(Post.objects.count(), 0) @@ -265,7 +232,7 @@ class FeedBuilderTestCase(TestCase): remote_identifier="a5479c66-8fae-11e9-8422-00163ef6bee7", rule=rule ) - with builder((mock_with_update_entries, mock_stream,)) as builder: + with builder((mock_with_update_entries, mock_stream)) as builder: builder.save() self.assertEquals(Post.objects.count(), 3) @@ -274,21 +241,17 @@ class FeedBuilderTestCase(TestCase): existing_second_post.refresh_from_db() self.assertEquals( - existing_first_post.title, - "Trump's 'genocidal taunts' will not end Iran - Zarif" + existing_first_post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif" ) - self.assertEquals( - existing_second_post.title, - "Huawei's Android loss: How it affects you" - ) + self.assertEquals(existing_second_post.title, "Huawei's Android loss: How it affects you") def test_html_sanitizing(self): builder = FeedBuilder rule = CollectionRuleFactory() mock_stream = MagicMock(rule=rule) - with builder((mock_with_html, mock_stream,)) as builder: + with builder((mock_with_html, mock_stream)) as builder: builder.save() post = Post.objects.get() diff --git a/src/newsreader/news/collection/tests/feed/client/tests.py b/src/newsreader/news/collection/tests/feed/client/tests.py index e236666..e49762d 100644 --- a/src/newsreader/news/collection/tests/feed/client/tests.py +++ b/src/newsreader/news/collection/tests/feed/client/tests.py @@ -1,5 +1,7 @@ from unittest.mock import MagicMock, patch +from .mocks import simple_mock + from django.test import TestCase from django.utils import timezone @@ -7,15 +9,17 @@ from newsreader.news.collection.exceptions import ( StreamDeniedException, StreamException, StreamNotFoundException, + StreamParseException, StreamTimeOutException, ) from newsreader.news.collection.feed import FeedClient from newsreader.news.collection.tests.factories import CollectionRuleFactory -from newsreader.news.collection.tests.feed.client.mocks import simple_mock class FeedClientTestCase(TestCase): def setUp(self): + self.maxDiff = None + self.patched_read = patch("newsreader.news.collection.feed.FeedStream.read") self.mocked_read = self.patched_read.start() @@ -85,3 +89,16 @@ class FeedClientTestCase(TestCase): self.assertEquals(stream.rule.succeeded, False) self.mocked_read.assert_called_once_with() + + def test_client_catches_stream_parse_exception(self): + rule = CollectionRuleFactory.create() + mock_stream = MagicMock(rule=rule) + self.mocked_read.side_effect = StreamParseException("Stream has wrong contents") + + with FeedClient([rule]) as client: + for data, stream in client: + self.assertEquals(data, {"entries": []}) + self.assertEquals(stream.rule.error, "Stream has wrong contents") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called_once_with() diff --git a/src/newsreader/news/collection/tests/feed/collector/tests.py b/src/newsreader/news/collection/tests/feed/collector/tests.py index db95ccb..0d14730 100644 --- a/src/newsreader/news/collection/tests/feed/collector/tests.py +++ b/src/newsreader/news/collection/tests/feed/collector/tests.py @@ -6,17 +6,26 @@ import pytz from freezegun import freeze_time -from django.test import TestCase -from django.utils import timezone - -from newsreader.news.collection.feed import FeedCollector -from newsreader.news.collection.tests.factories import CollectionRuleFactory -from newsreader.news.collection.tests.feed.collector.mocks import ( +from .mocks import ( duplicate_mock, empty_mock, multiple_mock, multiple_update_mock, ) + +from django.test import TestCase +from django.utils import timezone + +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamException, + StreamForbiddenException, + StreamNotFoundException, + StreamParseException, + StreamTimeOutException, +) +from newsreader.news.collection.feed import FeedCollector +from newsreader.news.collection.tests.factories import CollectionRuleFactory from newsreader.news.collection.utils import build_publication_date from newsreader.news.posts.models import Post from newsreader.news.posts.tests.factories import PostFactory @@ -24,14 +33,12 @@ from newsreader.news.posts.tests.factories import PostFactory class FeedCollectorTestCase(TestCase): def setUp(self): - self.patched_get = patch( - 'newsreader.news.collection.feed.requests.get' - ) - self.mocked_get = self.patched_get.start() + self.maxDiff = None - self.patched_parse = patch( - 'newsreader.news.collection.feed.FeedStream.parse' - ) + self.patched_get = patch("newsreader.news.collection.feed.fetch") + self.mocked_fetch = self.patched_get.start() + + self.patched_parse = patch("newsreader.news.collection.feed.FeedStream.parse") self.mocked_parse = self.patched_parse.start() def tearDown(self): @@ -54,7 +61,7 @@ class FeedCollectorTestCase(TestCase): @freeze_time("2019-10-30 12:30:00") def test_emtpy_batch(self): - self.mocked_get.return_value = MagicMock(status_code=200) + self.mocked_fetch.return_value = MagicMock() self.mocked_parse.return_value = empty_mock rule = CollectionRuleFactory() @@ -69,7 +76,7 @@ class FeedCollectorTestCase(TestCase): self.assertEquals(rule.last_suceeded, timezone.now()) def test_not_found(self): - self.mocked_get.return_value = MagicMock(status_code=404) + self.mocked_fetch.side_effect = StreamNotFoundException rule = CollectionRuleFactory() collector = FeedCollector() @@ -82,7 +89,7 @@ class FeedCollectorTestCase(TestCase): self.assertEquals(rule.error, "Stream not found") def test_denied(self): - self.mocked_get.return_value = MagicMock(status_code=404) + self.mocked_fetch.side_effect = StreamDeniedException last_suceeded = timezone.make_aware( datetime.combine(date=date(2019, 10, 30), time=time(12, 30)) ) @@ -95,11 +102,11 @@ class FeedCollectorTestCase(TestCase): self.assertEquals(Post.objects.count(), 0) self.assertEquals(rule.succeeded, False) - self.assertEquals(rule.error, "Stream not found") + self.assertEquals(rule.error, "Stream does not have sufficient permissions") self.assertEquals(rule.last_suceeded, last_suceeded) def test_forbidden(self): - self.mocked_get.return_value = MagicMock(status_code=403) + self.mocked_fetch.side_effect = StreamForbiddenException last_suceeded = timezone.make_aware( datetime.combine(date=date(2019, 10, 30), time=time(12, 30)) ) @@ -116,7 +123,7 @@ class FeedCollectorTestCase(TestCase): self.assertEquals(rule.last_suceeded, last_suceeded) def test_timed_out(self): - self.mocked_get.return_value = MagicMock(status_code=408) + self.mocked_fetch.side_effect = StreamTimeOutException last_suceeded = timezone.make_aware( datetime.combine(date=date(2019, 10, 30), time=time(12, 30)) ) @@ -138,8 +145,7 @@ class FeedCollectorTestCase(TestCase): rule = CollectionRuleFactory() _, aware_datetime = build_publication_date( - struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), - pytz.utc + struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), pytz.utc ) first_post = PostFactory( @@ -148,12 +154,11 @@ class FeedCollectorTestCase(TestCase): body="Foreign Minister Mohammad Javad Zarif says the US " "president should try showing Iranians some respect.", publication_date=aware_datetime, - rule=rule + rule=rule, ) _, aware_datetime = build_publication_date( - struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0,)), - pytz.utc + struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0)), pytz.utc ) second_post = PostFactory( @@ -162,22 +167,21 @@ class FeedCollectorTestCase(TestCase): body="Google's move to end business ties with Huawei will " "affect current devices and future purchases.", publication_date=aware_datetime, - rule=rule + rule=rule, ) _, aware_datetime = build_publication_date( - struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), - pytz.utc + struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), pytz.utc ) third_post = PostFactory( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", title="Birmingham head teacher threatened over LGBT lessons", body="Police are investigating the messages while an MP " - "calls for a protest exclusion zone \"to protect " - "children\".", + 'calls for a protest exclusion zone "to protect ' + 'children".', publication_date=aware_datetime, - rule=rule + rule=rule, ) collector = FeedCollector() @@ -201,7 +205,7 @@ class FeedCollectorTestCase(TestCase): title="Trump", body="Foreign Minister Mohammad Javad Zarif", publication_date=timezone.now(), - rule=rule + rule=rule, ) second_post = PostFactory( @@ -210,7 +214,7 @@ class FeedCollectorTestCase(TestCase): title="Huawei's Android loss: How it affects you", body="Google's move to end business ties with Huawei will", publication_date=timezone.now(), - rule=rule + rule=rule, ) third_post = PostFactory( @@ -219,7 +223,7 @@ class FeedCollectorTestCase(TestCase): title="Birmingham head teacher threatened over LGBT lessons", body="Police are investigating the messages while an MP", publication_date=timezone.now(), - rule=rule + rule=rule, ) collector = FeedCollector() @@ -235,17 +239,8 @@ class FeedCollectorTestCase(TestCase): self.assertEquals(rule.last_suceeded, timezone.now()) self.assertEquals(rule.error, None) - self.assertEquals( - first_post.title, - "Trump's 'genocidal taunts' will not end Iran - Zarif" - ) + self.assertEquals(first_post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif") - self.assertEquals( - second_post.title, - "Huawei's Android loss: How it affects you" - ) + self.assertEquals(second_post.title, "Huawei's Android loss: How it affects you") - self.assertEquals( - third_post.title, - 'Birmingham head teacher threatened over LGBT lessons' - ) + self.assertEquals(third_post.title, "Birmingham head teacher threatened over LGBT lessons") diff --git a/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py index a8600af..eff63cc 100644 --- a/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py +++ b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py @@ -9,7 +9,7 @@ from newsreader.news.posts.tests.factories import PostFactory class FeedDuplicateHandlerTestCase(TestCase): def setUp(self): - pass + self.maxDiff = None def test_duplicate_entries_with_remote_identifiers(self): rule = CollectionRuleFactory() @@ -19,7 +19,7 @@ class FeedDuplicateHandlerTestCase(TestCase): new_post = PostFactory.build( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", title="title got updated", - rule=rule + rule=rule, ) with FeedDuplicateHandler(rule) as duplicate_handler: @@ -45,7 +45,7 @@ class FeedDuplicateHandlerTestCase(TestCase): body="Google's move to end business ties with Huawei will affect current devices", publication_date=publication_date, remote_identifier=None, - rule=rule + rule=rule, ) new_post = PostFactory.build( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", @@ -53,7 +53,7 @@ class FeedDuplicateHandlerTestCase(TestCase): body="Google's move to end business ties with Huawei will affect current devices", publication_date=publication_date, remote_identifier=None, - rule=rule + rule=rule, ) with FeedDuplicateHandler(rule) as duplicate_handler: diff --git a/src/newsreader/news/collection/tests/feed/stream/mocks.py b/src/newsreader/news/collection/tests/feed/stream/mocks.py index 5853eb7..a098383 100644 --- a/src/newsreader/news/collection/tests/feed/stream/mocks.py +++ b/src/newsreader/news/collection/tests/feed/stream/mocks.py @@ -1,61 +1,62 @@ from time import struct_time simple_mock = { - 'bozo': 0, - 'encoding': 'utf-8', - 'entries': [{ - 'guidislink': False, - 'href': '', - 'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '1152', - 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', - 'width': '2048' - }], - 'published': 'Mon, 20 May 2019 16:07:37 GMT', - 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), - 'summary': 'Foreign Minister Mohammad Javad Zarif says the US ' - 'president should try showing Iranians some respect.', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': 'Foreign Minister Mohammad Javad ' - 'Zarif says the US president should ' - 'try showing Iranians some ' - 'respect.' - }, - 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': "Trump's 'genocidal taunts' will not " - 'end Iran - Zarif' - } - }], - 'feed': { - 'image': { - 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', - 'link': 'https://www.bbc.co.uk/news/', - 'title': 'BBC News - Home', - 'language': 'en-gb', - 'link': 'https://www.bbc.co.uk/news/' + "bozo": 1, + "encoding": "utf-8", + "entries": [ + { + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "link": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "links": [ + { + "href": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "1152", + "url": "http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg", + "width": "2048", + } + ], + "published": "Mon, 20 May 2019 16:07:37 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + "summary": "Foreign Minister Mohammad Javad Zarif says the US " + "president should try showing Iranians some respect.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Foreign Minister Mohammad Javad " + "Zarif says the US president should " + "try showing Iranians some " + "respect.", }, - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'title': 'BBC News - Home', + "title": "Trump's 'genocidal taunts' will not end Iran - Zarif", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif", + }, + } + ], + "feed": { + "image": { + "href": "https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif", + "link": "https://www.bbc.co.uk/news/", + "title": "BBC News - Home", + "language": "en-gb", + "link": "https://www.bbc.co.uk/news/", + }, + "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "title": "BBC News - Home", }, - 'href': 'http://feeds.bbci.co.uk/news/rss.xml', - 'status': 200, - 'version': 'rss20' + "href": "http://feeds.bbci.co.uk/news/rss.xml", + "status": 200, + "version": "rss20", } diff --git a/src/newsreader/news/collection/tests/feed/stream/tests.py b/src/newsreader/news/collection/tests/feed/stream/tests.py index 6e15194..3a7811b 100644 --- a/src/newsreader/news/collection/tests/feed/stream/tests.py +++ b/src/newsreader/news/collection/tests/feed/stream/tests.py @@ -1,5 +1,7 @@ from unittest.mock import MagicMock, patch +from .mocks import simple_mock + from django.test import TestCase from django.utils import timezone @@ -13,36 +15,32 @@ from newsreader.news.collection.exceptions import ( ) from newsreader.news.collection.feed import FeedStream from newsreader.news.collection.tests.factories import CollectionRuleFactory -from newsreader.news.collection.tests.feed.stream.mocks import simple_mock class FeedStreamTestCase(TestCase): def setUp(self): - self.patched_get = patch( - 'newsreader.news.collection.feed.requests.get' - ) - self.mocked_get = self.patched_get.start() + self.maxDiff = None - self.patched_parse = patch( - 'newsreader.news.collection.feed.FeedStream.parse' - ) - self.mocked_parse = self.patched_parse.start() + self.patched_fetch = patch("newsreader.news.collection.feed.fetch") + self.mocked_fetch = self.patched_fetch.start() def tearDown(self): patch.stopall() def test_simple_stream(self): - self.mocked_parse.return_value = simple_mock + self.mocked_fetch.return_value = MagicMock(content=simple_mock) rule = CollectionRuleFactory() stream = FeedStream(rule) - return_value = stream.read() - self.mocked_get.assert_called_once_with(rule.url) - self.assertEquals(return_value, (simple_mock, stream)) + data, stream = stream.read() + + self.mocked_fetch.assert_called_once_with(rule.url) + self.assertEquals(data["entries"], data["entries"]) + self.assertEquals(stream, stream) def test_stream_raises_exception(self): - self.mocked_parse.side_effect = StreamException + self.mocked_fetch.side_effect = StreamException rule = CollectionRuleFactory() stream = FeedStream(rule) @@ -50,10 +48,10 @@ class FeedStreamTestCase(TestCase): with self.assertRaises(StreamException): stream.read() - self.mocked_get.assert_called_once_with(rule.url) + self.mocked_fetch.assert_called_once_with(rule.url) def test_stream_raises_denied_exception(self): - self.mocked_get.return_value = MagicMock(status_code=401) + self.mocked_fetch.side_effect = StreamDeniedException rule = CollectionRuleFactory() stream = FeedStream(rule) @@ -61,10 +59,10 @@ class FeedStreamTestCase(TestCase): with self.assertRaises(StreamDeniedException): stream.read() - self.mocked_get.assert_called_once_with(rule.url) + self.mocked_fetch.assert_called_once_with(rule.url) def test_stream_raises_not_found_exception(self): - self.mocked_get.return_value = MagicMock(status_code=404) + self.mocked_fetch.side_effect = StreamNotFoundException rule = CollectionRuleFactory() stream = FeedStream(rule) @@ -72,10 +70,10 @@ class FeedStreamTestCase(TestCase): with self.assertRaises(StreamNotFoundException): stream.read() - self.mocked_get.assert_called_once_with(rule.url) + self.mocked_fetch.assert_called_once_with(rule.url) def test_stream_raises_time_out_exception(self): - self.mocked_get.return_value = MagicMock(status_code=408) + self.mocked_fetch.side_effect = StreamTimeOutException rule = CollectionRuleFactory() stream = FeedStream(rule) @@ -83,10 +81,10 @@ class FeedStreamTestCase(TestCase): with self.assertRaises(StreamTimeOutException): stream.read() - self.mocked_get.assert_called_once_with(rule.url) + self.mocked_fetch.assert_called_once_with(rule.url) def test_stream_raises_forbidden_exception(self): - self.mocked_get.return_value = MagicMock(status_code=403) + self.mocked_fetch.side_effect = StreamForbiddenException rule = CollectionRuleFactory() stream = FeedStream(rule) @@ -94,13 +92,12 @@ class FeedStreamTestCase(TestCase): with self.assertRaises(StreamForbiddenException): stream.read() - self.mocked_get.assert_called_once_with(rule.url) + self.mocked_fetch.assert_called_once_with(rule.url) @patch("newsreader.news.collection.feed.parse") def test_stream_raises_parse_exception(self, mocked_parse): - self.mocked_get.return_value = MagicMock(status_code=200) + self.mocked_fetch.return_value = MagicMock() mocked_parse.side_effect = TypeError - self.patched_parse.stop() rule = CollectionRuleFactory() stream = FeedStream(rule) diff --git a/src/newsreader/news/collection/tests/mocks.py b/src/newsreader/news/collection/tests/mocks.py new file mode 100644 index 0000000..32ad699 --- /dev/null +++ b/src/newsreader/news/collection/tests/mocks.py @@ -0,0 +1,47 @@ +simple_mock = """ + + +
+

Clickbait

+
+ + +""" + +simple_feed_mock = { + "bozo": 0, + "encoding": "utf-8", + "feed": { + "image": { + "href": "https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif", + "link": "https://www.bbc.co.uk/news/", + "title": "BBC News - Home", + "language": "en-gb", + "link": "https://www.bbc.co.uk/news/", + }, + "link": "https://www.bbc.co.uk/news/", + "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "title": "BBC News - Home", + }, + "href": "http://feeds.bbci.co.uk/news/rss.xml", + "status": 200, + "version": "rss20", +} + +feed_mock_without_link = { + "bozo": 0, + "encoding": "utf-8", + "feed": { + "image": { + "href": "https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif", + "link": "https://www.bbc.co.uk/news/", + "title": "BBC News - Home", + "language": "en-gb", + "link": "https://www.bbc.co.uk/news/", + }, + "title": "BBC News - Home", + }, + "href": "http://feeds.bbci.co.uk/news/rss.xml", + "status": 200, + "version": "rss20", +} diff --git a/src/newsreader/news/collection/tests/tests.py b/src/newsreader/news/collection/tests/tests.py new file mode 100644 index 0000000..08bc4e0 --- /dev/null +++ b/src/newsreader/news/collection/tests/tests.py @@ -0,0 +1,134 @@ +from unittest.mock import MagicMock, patch + +from bs4 import BeautifulSoup + +from .mocks import feed_mock_without_link, simple_feed_mock, simple_mock + +from django.test import TestCase + +from newsreader.news.collection.base import URLBuilder, WebsiteStream +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamException, + StreamForbiddenException, + StreamNotFoundException, + StreamParseException, + StreamTimeOutException, +) +from newsreader.news.collection.tests.factories import CollectionRuleFactory + + +class WebsiteStreamTestCase(TestCase): + def setUp(self): + self.patched_fetch = patch("newsreader.news.collection.base.fetch") + self.mocked_fetch = self.patched_fetch.start() + + def tearDown(self): + patch.stopall() + + def test_simple(self): + self.mocked_fetch.return_value = MagicMock(content=simple_mock) + + rule = CollectionRuleFactory() + stream = WebsiteStream(rule.url) + return_value = stream.read() + + self.mocked_fetch.assert_called_once_with(rule.url) + self.assertEquals(return_value, (BeautifulSoup(simple_mock, "lxml"), stream)) + + def test_raises_exception(self): + self.mocked_fetch.side_effect = StreamException + + rule = CollectionRuleFactory() + stream = WebsiteStream(rule.url) + + with self.assertRaises(StreamException): + stream.read() + + self.mocked_fetch.assert_called_once_with(rule.url) + + def test_raises_denied_exception(self): + self.mocked_fetch.side_effect = StreamDeniedException + + rule = CollectionRuleFactory() + stream = WebsiteStream(rule.url) + + with self.assertRaises(StreamDeniedException): + stream.read() + + self.mocked_fetch.assert_called_once_with(rule.url) + + def test_raises_stream_not_found_exception(self): + self.mocked_fetch.side_effect = StreamNotFoundException + + rule = CollectionRuleFactory() + stream = WebsiteStream(rule.url) + + with self.assertRaises(StreamNotFoundException): + stream.read() + + self.mocked_fetch.assert_called_once_with(rule.url) + + def test_stream_raises_time_out_exception(self): + self.mocked_fetch.side_effect = StreamTimeOutException + + rule = CollectionRuleFactory() + stream = WebsiteStream(rule.url) + + with self.assertRaises(StreamTimeOutException): + stream.read() + + self.mocked_fetch.assert_called_once_with(rule.url) + + def test_stream_raises_forbidden_exception(self): + self.mocked_fetch.side_effect = StreamForbiddenException + + rule = CollectionRuleFactory() + stream = WebsiteStream(rule.url) + + with self.assertRaises(StreamForbiddenException): + stream.read() + + self.mocked_fetch.assert_called_once_with(rule.url) + + @patch("newsreader.news.collection.base.WebsiteStream.parse") + def test_stream_raises_parse_exception(self, mocked_parse): + self.mocked_fetch.return_value = MagicMock() + mocked_parse.side_effect = StreamParseException + + rule = CollectionRuleFactory() + stream = WebsiteStream(rule.url) + + with self.assertRaises(StreamParseException): + stream.read() + + self.mocked_fetch.assert_called_once_with(rule.url) + + +class URLBuilderTestCase(TestCase): + def test_simple(self): + initial_rule = CollectionRuleFactory() + + with URLBuilder((simple_feed_mock, MagicMock(rule=initial_rule))) as builder: + rule, url = builder.build() + + self.assertEquals(rule.pk, initial_rule.pk) + self.assertEquals(url, "https://www.bbc.co.uk/news/") + + def test_no_link(self): + initial_rule = CollectionRuleFactory() + + with URLBuilder((feed_mock_without_link, MagicMock(rule=initial_rule))) as builder: + rule, url = builder.build() + + self.assertEquals(rule.pk, initial_rule.pk) + self.assertEquals(url, None) + + def test_no_data(self): + initial_rule = CollectionRuleFactory() + + with URLBuilder((None, MagicMock(rule=initial_rule))) as builder: + rule, url = builder.build() + + self.assertEquals(rule.pk, initial_rule.pk) + self.assertEquals(url, None) diff --git a/src/newsreader/news/collection/tests/utils/__init__.py b/src/newsreader/news/collection/tests/utils/__init__.py new file mode 100644 index 0000000..8baa6e5 --- /dev/null +++ b/src/newsreader/news/collection/tests/utils/__init__.py @@ -0,0 +1 @@ +from .tests import * diff --git a/src/newsreader/news/collection/tests/utils/tests.py b/src/newsreader/news/collection/tests/utils/tests.py new file mode 100644 index 0000000..3c95df0 --- /dev/null +++ b/src/newsreader/news/collection/tests/utils/tests.py @@ -0,0 +1,57 @@ +from unittest.mock import MagicMock, patch + +from django.test import TestCase + +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamForbiddenException, + StreamNotFoundException, + StreamTimeOutException, +) +from newsreader.news.collection.utils import fetch + + +class FetchTestCase(TestCase): + def setUp(self): + self.patched_get = patch("newsreader.news.collection.utils.requests.get") + self.mocked_get = self.patched_get.start() + + def test_simple(self): + self.mocked_get.return_value = MagicMock(status_code=200, content="content") + + url = "https://www.bbc.co.uk/news" + response = fetch(url) + + self.assertEquals(response.content, "content") + + def test_raises_not_found(self): + self.mocked_get.return_value = MagicMock(status_code=404) + + url = "https://www.bbc.co.uk/news" + + with self.assertRaises(StreamNotFoundException): + fetch(url) + + def test_raises_denied(self): + self.mocked_get.return_value = MagicMock(status_code=401) + + url = "https://www.bbc.co.uk/news" + + with self.assertRaises(StreamDeniedException): + fetch(url) + + def test_raises_forbidden(self): + self.mocked_get.return_value = MagicMock(status_code=403) + + url = "https://www.bbc.co.uk/news" + + with self.assertRaises(StreamForbiddenException): + fetch(url) + + def test_raises_timed_out(self): + self.mocked_get.return_value = MagicMock(status_code=408) + + url = "https://www.bbc.co.uk/news" + + with self.assertRaises(StreamTimeOutException): + fetch(url) diff --git a/src/newsreader/news/collection/utils.py b/src/newsreader/news/collection/utils.py index 172fb54..17f379a 100644 --- a/src/newsreader/news/collection/utils.py +++ b/src/newsreader/news/collection/utils.py @@ -1,9 +1,15 @@ from datetime import datetime, tzinfo from time import mktime, struct_time -from typing import Tuple +from typing import Optional, Tuple + +import requests + +from requests.models import Response from django.utils import timezone +from newsreader.news.collection.response_handler import ResponseHandler + def build_publication_date(dt: struct_time, tz: tzinfo) -> Tuple: try: @@ -12,3 +18,12 @@ def build_publication_date(dt: struct_time, tz: tzinfo) -> Tuple: except TypeError: return False, None return True, published_parsed + + +def fetch(url: str) -> Optional[Response]: + response = requests.get(url) + + with ResponseHandler(response) as response_handler: + response_handler.handle_response() + + return response From cfd064ec85977dbd15c2026310cf7ba6fdd6de3f Mon Sep 17 00:00:00 2001 From: Sonny Date: Mon, 1 Jul 2019 09:39:48 +0200 Subject: [PATCH 006/422] Add missing requirements --- requirements/base.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements/base.txt b/requirements/base.txt index 68e2d7d..00667bb 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,7 +1,9 @@ bleach==3.1.0 +beautifulsoup4==4.7.1 certifi==2019.3.9 chardet==3.0.4 Django==2.2 +lxml==4.3.4 feedparser==5.2.1 idna==2.8 pytz==2018.9 From ed658c4dfdebf8a1ea18bcfc1c3844923cbd5519 Mon Sep 17 00:00:00 2001 From: Sonny Date: Mon, 1 Jul 2019 11:38:23 +0200 Subject: [PATCH 007/422] Handle request exceptions --- src/newsreader/news/collection/exceptions.py | 4 ++ .../news/collection/response_handler.py | 33 +++++++++--- .../news/collection/tests/utils/tests.py | 50 +++++++++++++++++++ src/newsreader/news/collection/utils.py | 15 +++--- 4 files changed, 88 insertions(+), 14 deletions(-) diff --git a/src/newsreader/news/collection/exceptions.py b/src/newsreader/news/collection/exceptions.py index 8e12da1..e636638 100644 --- a/src/newsreader/news/collection/exceptions.py +++ b/src/newsreader/news/collection/exceptions.py @@ -26,3 +26,7 @@ class StreamForbiddenException(StreamException): class StreamParseException(StreamException): message = "Stream could not be parsed" + + +class StreamConnectionError(StreamException): + message = "A connection to the stream could not be made" diff --git a/src/newsreader/news/collection/response_handler.py b/src/newsreader/news/collection/response_handler.py index ff46171..e412475 100644 --- a/src/newsreader/news/collection/response_handler.py +++ b/src/newsreader/news/collection/response_handler.py @@ -1,9 +1,18 @@ from typing import ContextManager from requests import Response +from requests.exceptions import ConnectionError as RequestConnectionError +from requests.exceptions import ( + HTTPError, + RequestException, + SSLError, + TooManyRedirects, +) from newsreader.news.collection.exceptions import ( + StreamConnectionError, StreamDeniedException, + StreamException, StreamForbiddenException, StreamNotFoundException, StreamTimeOutException, @@ -11,24 +20,32 @@ from newsreader.news.collection.exceptions import ( class ResponseHandler: - message_mapping = { + status_code_mapping = { 404: StreamNotFoundException, 401: StreamDeniedException, 403: StreamForbiddenException, 408: StreamTimeOutException, } - def __init__(self, response: Response) -> None: - self.response = response + exception_mapping = {RequestConnectionError: StreamConnectionError} def __enter__(self) -> ContextManager: return self - def handle_response(self) -> None: - status_code = self.response.status_code + def handle_response(self, response) -> None: + status_code = response.status_code - if status_code in self.message_mapping: - raise self.message_mapping[status_code] + if status_code in self.status_code_mapping: + raise self.status_code_mapping[status_code] + + def handle_exception(self, exception): + try: + stream_exception = self.exception_mapping[type(exception)] + except KeyError: + stream_exception = StreamException + + message = getattr(exception, "message", str(exception)) + raise stream_exception(message=message) from exception def __exit__(self, *args, **kwargs) -> None: - self.response = None + pass diff --git a/src/newsreader/news/collection/tests/utils/tests.py b/src/newsreader/news/collection/tests/utils/tests.py index 3c95df0..e2f0fcf 100644 --- a/src/newsreader/news/collection/tests/utils/tests.py +++ b/src/newsreader/news/collection/tests/utils/tests.py @@ -1,9 +1,19 @@ from unittest.mock import MagicMock, patch +from requests.exceptions import ConnectionError as RequestConnectionError +from requests.exceptions import ( + HTTPError, + RequestException, + SSLError, + TooManyRedirects, +) + from django.test import TestCase from newsreader.news.collection.exceptions import ( + StreamConnectionError, StreamDeniedException, + StreamException, StreamForbiddenException, StreamNotFoundException, StreamTimeOutException, @@ -55,3 +65,43 @@ class FetchTestCase(TestCase): with self.assertRaises(StreamTimeOutException): fetch(url) + + def test_raises_stream_error_on_ssl_error(self): + self.mocked_get.side_effect = SSLError + + url = "https://www.bbc.co.uk/news" + + with self.assertRaises(StreamException): + fetch(url) + + def test_raises_stream_error_on_connection_error(self): + self.mocked_get.side_effect = RequestConnectionError + + url = "https://www.bbc.co.uk/news" + + with self.assertRaises(StreamConnectionError): + fetch(url) + + def test_raises_stream_error_on_http_error(self): + self.mocked_get.side_effect = HTTPError + + url = "https://www.bbc.co.uk/news" + + with self.assertRaises(StreamException): + fetch(url) + + def test_raises_stream_error_on_request_exception(self): + self.mocked_get.side_effect = RequestException + + url = "https://www.bbc.co.uk/news" + + with self.assertRaises(StreamException): + fetch(url) + + def test_raises_stream_error_on_too_many_redirects(self): + self.mocked_get.side_effect = TooManyRedirects + + url = "https://www.bbc.co.uk/news" + + with self.assertRaises(StreamException): + fetch(url) diff --git a/src/newsreader/news/collection/utils.py b/src/newsreader/news/collection/utils.py index 17f379a..527587d 100644 --- a/src/newsreader/news/collection/utils.py +++ b/src/newsreader/news/collection/utils.py @@ -1,9 +1,10 @@ from datetime import datetime, tzinfo from time import mktime, struct_time -from typing import Optional, Tuple +from typing import Tuple import requests +from requests.exceptions import RequestException from requests.models import Response from django.utils import timezone @@ -20,10 +21,12 @@ def build_publication_date(dt: struct_time, tz: tzinfo) -> Tuple: return True, published_parsed -def fetch(url: str) -> Optional[Response]: - response = requests.get(url) - - with ResponseHandler(response) as response_handler: - response_handler.handle_response() +def fetch(url: str) -> Response: + with ResponseHandler() as response_handler: + try: + response = requests.get(url) + response_handler.handle_response(response) + except RequestException as exception: + response_handler.handle_exception(exception) return response From 982c5bb132190eeb235ea0a73bfa03a6b254154e Mon Sep 17 00:00:00 2001 From: Sonny Date: Mon, 1 Jul 2019 12:11:44 +0200 Subject: [PATCH 008/422] Update isort & rerun formatting --- .isort.cfg | 9 +- src/newsreader/conf/base.py | 22 +- src/newsreader/conf/dev.py | 1 + src/newsreader/core/admin.py | 1 + src/newsreader/core/apps.py | 2 +- src/newsreader/core/models.py | 1 + src/newsreader/core/tests.py | 1 + src/newsreader/core/views.py | 1 + src/newsreader/news/collection/apps.py | 2 +- src/newsreader/news/collection/base.py | 4 +- src/newsreader/news/collection/favicon.py | 1 + src/newsreader/news/collection/feed.py | 4 +- .../collection/management/commands/collect.py | 2 +- .../collection/migrations/0001_initial.py | 21 +- .../migrations/0002_auto_20190410_2028.py | 10 +- .../0003_collectionrule_category.py | 17 +- .../0004_collectionrule_timezone.py | 1100 ++++++------ .../migrations/0005_auto_20190521_1941.py | 16 +- .../migrations/0006_collectionrule_error.py | 10 +- src/newsreader/news/collection/models.py | 4 +- .../collection/tests/favicon/builder/mocks.py | 1 + .../collection/tests/favicon/builder/tests.py | 4 +- .../collection/tests/favicon/client/mocks.py | 1 + .../tests/favicon/collector/mocks.py | 1 + .../tests/favicon/collector/tests.py | 10 +- .../tests/feed/builder/mock_html.py | 4 +- .../collection/tests/feed/builder/mocks.py | 1562 +++++++++-------- .../collection/tests/feed/builder/tests.py | 10 +- .../collection/tests/feed/client/mocks.py | 112 +- .../collection/tests/feed/client/tests.py | 4 +- .../collection/tests/feed/collector/mocks.py | 760 ++++---- .../collection/tests/feed/collector/tests.py | 20 +- .../collection/tests/feed/stream/mocks.py | 1 + .../collection/tests/feed/stream/tests.py | 4 +- src/newsreader/news/collection/tests/tests.py | 8 +- .../news/collection/tests/utils/tests.py | 4 +- src/newsreader/news/collection/utils.py | 4 +- src/newsreader/news/collection/views.py | 1 + src/newsreader/news/posts/admin.py | 21 +- src/newsreader/news/posts/apps.py | 2 +- .../news/posts/migrations/0001_initial.py | 69 +- .../migrations/0002_auto_20190520_2206.py | 13 +- .../migrations/0003_auto_20190520_2031.py | 10 +- .../migrations/0004_auto_20190521_1941.py | 13 +- .../migrations/0005_auto_20190608_1054.py | 14 +- .../migrations/0006_auto_20190608_1520.py | 20 +- src/newsreader/news/posts/models.py | 4 +- src/newsreader/news/posts/tests/factories.py | 4 +- src/newsreader/news/posts/views.py | 1 + src/newsreader/urls.py | 5 +- src/newsreader/wsgi.py | 3 +- 51 files changed, 1993 insertions(+), 1926 deletions(-) diff --git a/.isort.cfg b/.isort.cfg index e453b8d..2b81405 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -2,6 +2,11 @@ include_trailing_comma = true line_length = 80 multi_line_output = 3 -skip = env/ -forced_separate=django, newsreader +skip = env/, venv/ +default_section = THIRDPARTY +known_first_party = newsreader +known_django = django +sections = FUTURE,STDLIB,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER +lines_between_types=1 +lines_after_imports=2 lines_between_types=1 diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 4b9f9df..16927e4 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/2.2/ref/settings/ import os + # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -62,9 +63,9 @@ TEMPLATES = [ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", - ], + ] }, - }, + } ] WSGI_APPLICATION = "newsreader.wsgi.application" @@ -82,19 +83,10 @@ DATABASES = { # Password validation # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ - { - "NAME": - "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", - }, + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] # Internationalization diff --git a/src/newsreader/conf/dev.py b/src/newsreader/conf/dev.py index 8b5de69..245a78c 100644 --- a/src/newsreader/conf/dev.py +++ b/src/newsreader/conf/dev.py @@ -1,5 +1,6 @@ from .base import * + # Development settings DEBUG = True diff --git a/src/newsreader/core/admin.py b/src/newsreader/core/admin.py index 8c38f3f..a011d19 100644 --- a/src/newsreader/core/admin.py +++ b/src/newsreader/core/admin.py @@ -1,3 +1,4 @@ from django.contrib import admin + # Register your models here. diff --git a/src/newsreader/core/apps.py b/src/newsreader/core/apps.py index 26f78a8..5ef1d60 100644 --- a/src/newsreader/core/apps.py +++ b/src/newsreader/core/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class CoreConfig(AppConfig): - name = 'core' + name = "core" diff --git a/src/newsreader/core/models.py b/src/newsreader/core/models.py index 4bd2e28..2e696fb 100644 --- a/src/newsreader/core/models.py +++ b/src/newsreader/core/models.py @@ -6,6 +6,7 @@ class TimeStampedModel(models.Model): An abstract base class model that provides self- updating ``created`` and ``modified`` fields. """ + created = models.DateTimeField(auto_now_add=True) modified = models.DateTimeField(auto_now=True) diff --git a/src/newsreader/core/tests.py b/src/newsreader/core/tests.py index 7ce503c..7c72b39 100644 --- a/src/newsreader/core/tests.py +++ b/src/newsreader/core/tests.py @@ -1,3 +1,4 @@ from django.test import TestCase + # Create your tests here. diff --git a/src/newsreader/core/views.py b/src/newsreader/core/views.py index 91ea44a..dc1ba72 100644 --- a/src/newsreader/core/views.py +++ b/src/newsreader/core/views.py @@ -1,3 +1,4 @@ from django.shortcuts import render + # Create your views here. diff --git a/src/newsreader/news/collection/apps.py b/src/newsreader/news/collection/apps.py index 1454371..1f4c1c0 100644 --- a/src/newsreader/news/collection/apps.py +++ b/src/newsreader/news/collection/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class CollectionConfig(AppConfig): - name = 'collection' + name = "collection" diff --git a/src/newsreader/news/collection/base.py b/src/newsreader/news/collection/base.py index 2f392b7..c202df8 100644 --- a/src/newsreader/news/collection/base.py +++ b/src/newsreader/news/collection/base.py @@ -1,11 +1,11 @@ from typing import ContextManager, Dict, List, Optional, Tuple +from django.utils import timezone + import requests from bs4 import BeautifulSoup -from django.utils import timezone - from newsreader.news.collection.exceptions import StreamParseException from newsreader.news.collection.models import CollectionRule from newsreader.news.collection.utils import fetch diff --git a/src/newsreader/news/collection/favicon.py b/src/newsreader/news/collection/favicon.py index 8270692..05bd6c9 100644 --- a/src/newsreader/news/collection/favicon.py +++ b/src/newsreader/news/collection/favicon.py @@ -13,6 +13,7 @@ from newsreader.news.collection.base import ( from newsreader.news.collection.exceptions import StreamException from newsreader.news.collection.feed import FeedClient + LINK_RELS = ["icon", "shortcut icon", "apple-touch-icon", "apple-touch-icon-precomposed"] diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index ef66b69..2cf248c 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -1,13 +1,13 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from typing import ContextManager, Dict, Generator, List, Optional, Tuple +from django.utils import timezone + import bleach import pytz from feedparser import parse -from django.utils import timezone - from newsreader.news.collection.base import Builder, Client, Collector, Stream from newsreader.news.collection.exceptions import ( StreamDeniedException, diff --git a/src/newsreader/news/collection/management/commands/collect.py b/src/newsreader/news/collection/management/commands/collect.py index c72301f..855089f 100644 --- a/src/newsreader/news/collection/management/commands/collect.py +++ b/src/newsreader/news/collection/management/commands/collect.py @@ -5,7 +5,7 @@ from newsreader.news.collection.models import CollectionRule class Command(BaseCommand): - help = 'Collects Atom/RSS feeds' + help = "Collects Atom/RSS feeds" def handle(self, *args, **options): CollectionRule.objects.all() diff --git a/src/newsreader/news/collection/migrations/0001_initial.py b/src/newsreader/news/collection/migrations/0001_initial.py index 354a97f..1091b7a 100644 --- a/src/newsreader/news/collection/migrations/0001_initial.py +++ b/src/newsreader/news/collection/migrations/0001_initial.py @@ -11,21 +11,18 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='CollectionRule', + name="CollectionRule", fields=[ ( - 'id', + "id", models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name='ID' - ) + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), ), - ('name', models.CharField(max_length=100)), - ('url', models.URLField()), - ('last_suceeded', models.DateTimeField()), - ('succeeded', models.BooleanField(default=False)), + ("name", models.CharField(max_length=100)), + ("url", models.URLField()), + ("last_suceeded", models.DateTimeField()), + ("succeeded", models.BooleanField(default=False)), ], - ), + ) ] diff --git a/src/newsreader/news/collection/migrations/0002_auto_20190410_2028.py b/src/newsreader/news/collection/migrations/0002_auto_20190410_2028.py index 9c0807e..ce45e0e 100644 --- a/src/newsreader/news/collection/migrations/0002_auto_20190410_2028.py +++ b/src/newsreader/news/collection/migrations/0002_auto_20190410_2028.py @@ -5,14 +5,12 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('collection', '0001_initial'), - ] + dependencies = [("collection", "0001_initial")] operations = [ migrations.AlterField( - model_name='collectionrule', - name='last_suceeded', + model_name="collectionrule", + name="last_suceeded", field=models.DateTimeField(blank=True, null=True), - ), + ) ] diff --git a/src/newsreader/news/collection/migrations/0003_collectionrule_category.py b/src/newsreader/news/collection/migrations/0003_collectionrule_category.py index 4c3f267..b15f7f7 100644 --- a/src/newsreader/news/collection/migrations/0003_collectionrule_category.py +++ b/src/newsreader/news/collection/migrations/0003_collectionrule_category.py @@ -7,22 +7,19 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('posts', '0002_auto_20190520_2206'), - ('collection', '0002_auto_20190410_2028'), - ] + dependencies = [("posts", "0002_auto_20190520_2206"), ("collection", "0002_auto_20190410_2028")] operations = [ migrations.AddField( - model_name='collectionrule', - name='category', + model_name="collectionrule", + name="category", field=models.ForeignKey( blank=True, - help_text='Posts from this rule will be tagged with this category', + help_text="Posts from this rule will be tagged with this category", null=True, on_delete=django.db.models.deletion.SET_NULL, - to='posts.Category', - verbose_name='Category' + to="posts.Category", + verbose_name="Category", ), - ), + ) ] diff --git a/src/newsreader/news/collection/migrations/0004_collectionrule_timezone.py b/src/newsreader/news/collection/migrations/0004_collectionrule_timezone.py index e5943dc..837e625 100644 --- a/src/newsreader/news/collection/migrations/0004_collectionrule_timezone.py +++ b/src/newsreader/news/collection/migrations/0004_collectionrule_timezone.py @@ -5,513 +5,609 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('collection', '0003_collectionrule_category'), - ] + dependencies = [("collection", "0003_collectionrule_category")] operations = [ migrations.AddField( - model_name='collectionrule', - name='timezone', + model_name="collectionrule", + name="timezone", field=models.CharField( choices=[ - ('Africa/Abidjan', 'Africa/Abidjan'), - ('Africa/Accra', 'Africa/Accra'), - ('Africa/Addis_Ababa', 'Africa/Addis_Ababa'), - ('Africa/Algiers', 'Africa/Algiers'), - ('Africa/Asmara', - 'Africa/Asmara'), ('Africa/Asmera', 'Africa/Asmera'), - ('Africa/Bamako', - 'Africa/Bamako'), ('Africa/Bangui', 'Africa/Bangui'), - ('Africa/Banjul', 'Africa/Banjul'), - ('Africa/Bissau', 'Africa/Bissau'), - ('Africa/Blantyre', 'Africa/Blantyre'), - ('Africa/Brazzaville', 'Africa/Brazzaville'), - ('Africa/Bujumbura', 'Africa/Bujumbura'), - ('Africa/Cairo', 'Africa/Cairo'), - ('Africa/Casablanca', 'Africa/Casablanca'), - ('Africa/Ceuta', - 'Africa/Ceuta'), ('Africa/Conakry', 'Africa/Conakry'), - ('Africa/Dakar', 'Africa/Dakar'), - ('Africa/Dar_es_Salaam', 'Africa/Dar_es_Salaam'), - ('Africa/Djibouti', 'Africa/Djibouti'), - ('Africa/Douala', 'Africa/Douala'), - ('Africa/El_Aaiun', 'Africa/El_Aaiun'), - ('Africa/Freetown', 'Africa/Freetown'), - ('Africa/Gaborone', 'Africa/Gaborone'), - ('Africa/Harare', 'Africa/Harare'), - ('Africa/Johannesburg', 'Africa/Johannesburg'), - ('Africa/Juba', 'Africa/Juba'), ('Africa/Kampala', 'Africa/Kampala'), - ('Africa/Khartoum', 'Africa/Khartoum'), - ('Africa/Kigali', 'Africa/Kigali'), - ('Africa/Kinshasa', 'Africa/Kinshasa'), - ('Africa/Lagos', 'Africa/Lagos'), - ('Africa/Libreville', 'Africa/Libreville'), - ('Africa/Lome', 'Africa/Lome'), ('Africa/Luanda', 'Africa/Luanda'), - ('Africa/Lubumbashi', 'Africa/Lubumbashi'), - ('Africa/Lusaka', - 'Africa/Lusaka'), ('Africa/Malabo', 'Africa/Malabo'), - ('Africa/Maputo', 'Africa/Maputo'), - ('Africa/Maseru', 'Africa/Maseru'), - ('Africa/Mbabane', 'Africa/Mbabane'), - ('Africa/Mogadishu', 'Africa/Mogadishu'), - ('Africa/Monrovia', 'Africa/Monrovia'), - ('Africa/Nairobi', 'Africa/Nairobi'), - ('Africa/Ndjamena', 'Africa/Ndjamena'), - ('Africa/Niamey', 'Africa/Niamey'), - ('Africa/Nouakchott', 'Africa/Nouakchott'), - ('Africa/Ouagadougou', 'Africa/Ouagadougou'), - ('Africa/Porto-Novo', 'Africa/Porto-Novo'), - ('Africa/Sao_Tome', 'Africa/Sao_Tome'), - ('Africa/Timbuktu', 'Africa/Timbuktu'), - ('Africa/Tripoli', 'Africa/Tripoli'), - ('Africa/Tunis', 'Africa/Tunis'), - ('Africa/Windhoek', 'Africa/Windhoek'), - ('America/Adak', 'America/Adak'), - ('America/Anchorage', 'America/Anchorage'), - ('America/Anguilla', 'America/Anguilla'), - ('America/Antigua', 'America/Antigua'), - ('America/Araguaina', 'America/Araguaina'), - ('America/Argentina/Buenos_Aires', 'America/Argentina/Buenos_Aires'), - ('America/Argentina/Catamarca', 'America/Argentina/Catamarca'), - ( - 'America/Argentina/ComodRivadavia', - 'America/Argentina/ComodRivadavia' - ), ('America/Argentina/Cordoba', 'America/Argentina/Cordoba'), - ('America/Argentina/Jujuy', 'America/Argentina/Jujuy'), - ('America/Argentina/La_Rioja', 'America/Argentina/La_Rioja'), - ('America/Argentina/Mendoza', 'America/Argentina/Mendoza'), - ('America/Argentina/Rio_Gallegos', 'America/Argentina/Rio_Gallegos'), - ('America/Argentina/Salta', 'America/Argentina/Salta'), - ('America/Argentina/San_Juan', 'America/Argentina/San_Juan'), - ('America/Argentina/San_Luis', 'America/Argentina/San_Luis'), - ('America/Argentina/Tucuman', 'America/Argentina/Tucuman'), - ('America/Argentina/Ushuaia', 'America/Argentina/Ushuaia'), - ('America/Aruba', 'America/Aruba'), - ('America/Asuncion', 'America/Asuncion'), - ('America/Atikokan', 'America/Atikokan'), - ('America/Atka', 'America/Atka'), ('America/Bahia', 'America/Bahia'), - ('America/Bahia_Banderas', 'America/Bahia_Banderas'), - ('America/Barbados', 'America/Barbados'), - ('America/Belem', 'America/Belem'), - ('America/Belize', 'America/Belize'), - ('America/Blanc-Sablon', 'America/Blanc-Sablon'), - ('America/Boa_Vista', 'America/Boa_Vista'), - ('America/Bogota', 'America/Bogota'), - ('America/Boise', 'America/Boise'), - ('America/Buenos_Aires', 'America/Buenos_Aires'), - ('America/Cambridge_Bay', 'America/Cambridge_Bay'), - ('America/Campo_Grande', 'America/Campo_Grande'), - ('America/Cancun', 'America/Cancun'), - ('America/Caracas', 'America/Caracas'), - ('America/Catamarca', 'America/Catamarca'), - ('America/Cayenne', 'America/Cayenne'), - ('America/Cayman', 'America/Cayman'), - ('America/Chicago', 'America/Chicago'), - ('America/Chihuahua', 'America/Chihuahua'), - ('America/Coral_Harbour', 'America/Coral_Harbour'), - ('America/Cordoba', 'America/Cordoba'), - ('America/Costa_Rica', 'America/Costa_Rica'), - ('America/Creston', 'America/Creston'), - ('America/Cuiaba', 'America/Cuiaba'), - ('America/Curacao', 'America/Curacao'), - ('America/Danmarkshavn', 'America/Danmarkshavn'), - ('America/Dawson', 'America/Dawson'), - ('America/Dawson_Creek', 'America/Dawson_Creek'), - ('America/Denver', 'America/Denver'), - ('America/Detroit', 'America/Detroit'), - ('America/Dominica', 'America/Dominica'), - ('America/Edmonton', 'America/Edmonton'), - ('America/Eirunepe', 'America/Eirunepe'), - ('America/El_Salvador', 'America/El_Salvador'), - ('America/Ensenada', 'America/Ensenada'), - ('America/Fort_Nelson', 'America/Fort_Nelson'), - ('America/Fort_Wayne', 'America/Fort_Wayne'), - ('America/Fortaleza', 'America/Fortaleza'), - ('America/Glace_Bay', 'America/Glace_Bay'), - ('America/Godthab', 'America/Godthab'), - ('America/Goose_Bay', 'America/Goose_Bay'), - ('America/Grand_Turk', 'America/Grand_Turk'), - ('America/Grenada', 'America/Grenada'), - ('America/Guadeloupe', 'America/Guadeloupe'), - ('America/Guatemala', 'America/Guatemala'), - ('America/Guayaquil', 'America/Guayaquil'), - ('America/Guyana', 'America/Guyana'), - ('America/Halifax', 'America/Halifax'), - ('America/Havana', 'America/Havana'), - ('America/Hermosillo', 'America/Hermosillo'), - ('America/Indiana/Indianapolis', 'America/Indiana/Indianapolis'), - ('America/Indiana/Knox', 'America/Indiana/Knox'), - ('America/Indiana/Marengo', 'America/Indiana/Marengo'), - ('America/Indiana/Petersburg', 'America/Indiana/Petersburg'), - ('America/Indiana/Tell_City', 'America/Indiana/Tell_City'), - ('America/Indiana/Vevay', 'America/Indiana/Vevay'), - ('America/Indiana/Vincennes', 'America/Indiana/Vincennes'), - ('America/Indiana/Winamac', 'America/Indiana/Winamac'), - ('America/Indianapolis', 'America/Indianapolis'), - ('America/Inuvik', 'America/Inuvik'), - ('America/Iqaluit', 'America/Iqaluit'), - ('America/Jamaica', 'America/Jamaica'), - ('America/Jujuy', 'America/Jujuy'), - ('America/Juneau', 'America/Juneau'), - ('America/Kentucky/Louisville', 'America/Kentucky/Louisville'), - ('America/Kentucky/Monticello', 'America/Kentucky/Monticello'), - ('America/Knox_IN', 'America/Knox_IN'), - ('America/Kralendijk', 'America/Kralendijk'), - ('America/La_Paz', - 'America/La_Paz'), ('America/Lima', 'America/Lima'), - ('America/Los_Angeles', 'America/Los_Angeles'), - ('America/Louisville', 'America/Louisville'), - ('America/Lower_Princes', 'America/Lower_Princes'), - ('America/Maceio', 'America/Maceio'), - ('America/Managua', 'America/Managua'), - ('America/Manaus', 'America/Manaus'), - ('America/Marigot', 'America/Marigot'), - ('America/Martinique', 'America/Martinique'), - ('America/Matamoros', 'America/Matamoros'), - ('America/Mazatlan', 'America/Mazatlan'), - ('America/Mendoza', 'America/Mendoza'), - ('America/Menominee', 'America/Menominee'), - ('America/Merida', 'America/Merida'), - ('America/Metlakatla', 'America/Metlakatla'), - ('America/Mexico_City', 'America/Mexico_City'), - ('America/Miquelon', 'America/Miquelon'), - ('America/Moncton', 'America/Moncton'), - ('America/Monterrey', 'America/Monterrey'), - ('America/Montevideo', 'America/Montevideo'), - ('America/Montreal', 'America/Montreal'), - ('America/Montserrat', 'America/Montserrat'), - ('America/Nassau', 'America/Nassau'), - ('America/New_York', 'America/New_York'), - ('America/Nipigon', 'America/Nipigon'), - ('America/Nome', 'America/Nome'), - ('America/Noronha', 'America/Noronha'), - ('America/North_Dakota/Beulah', 'America/North_Dakota/Beulah'), - ('America/North_Dakota/Center', 'America/North_Dakota/Center'), - ('America/North_Dakota/New_Salem', 'America/North_Dakota/New_Salem'), - ('America/Ojinaga', 'America/Ojinaga'), - ('America/Panama', 'America/Panama'), - ('America/Pangnirtung', 'America/Pangnirtung'), - ('America/Paramaribo', 'America/Paramaribo'), - ('America/Phoenix', 'America/Phoenix'), - ('America/Port-au-Prince', 'America/Port-au-Prince'), - ('America/Port_of_Spain', 'America/Port_of_Spain'), - ('America/Porto_Acre', 'America/Porto_Acre'), - ('America/Porto_Velho', 'America/Porto_Velho'), - ('America/Puerto_Rico', 'America/Puerto_Rico'), - ('America/Punta_Arenas', 'America/Punta_Arenas'), - ('America/Rainy_River', 'America/Rainy_River'), - ('America/Rankin_Inlet', 'America/Rankin_Inlet'), - ('America/Recife', 'America/Recife'), - ('America/Regina', 'America/Regina'), - ('America/Resolute', 'America/Resolute'), - ('America/Rio_Branco', 'America/Rio_Branco'), - ('America/Rosario', 'America/Rosario'), - ('America/Santa_Isabel', 'America/Santa_Isabel'), - ('America/Santarem', 'America/Santarem'), - ('America/Santiago', 'America/Santiago'), - ('America/Santo_Domingo', 'America/Santo_Domingo'), - ('America/Sao_Paulo', 'America/Sao_Paulo'), - ('America/Scoresbysund', 'America/Scoresbysund'), - ('America/Shiprock', 'America/Shiprock'), - ('America/Sitka', 'America/Sitka'), - ('America/St_Barthelemy', 'America/St_Barthelemy'), - ('America/St_Johns', 'America/St_Johns'), - ('America/St_Kitts', 'America/St_Kitts'), - ('America/St_Lucia', 'America/St_Lucia'), - ('America/St_Thomas', 'America/St_Thomas'), - ('America/St_Vincent', 'America/St_Vincent'), - ('America/Swift_Current', 'America/Swift_Current'), - ('America/Tegucigalpa', 'America/Tegucigalpa'), - ('America/Thule', 'America/Thule'), - ('America/Thunder_Bay', 'America/Thunder_Bay'), - ('America/Tijuana', 'America/Tijuana'), - ('America/Toronto', 'America/Toronto'), - ('America/Tortola', 'America/Tortola'), - ('America/Vancouver', 'America/Vancouver'), - ('America/Virgin', 'America/Virgin'), - ('America/Whitehorse', 'America/Whitehorse'), - ('America/Winnipeg', 'America/Winnipeg'), - ('America/Yakutat', 'America/Yakutat'), - ('America/Yellowknife', 'America/Yellowknife'), - ('Antarctica/Casey', 'Antarctica/Casey'), - ('Antarctica/Davis', 'Antarctica/Davis'), - ('Antarctica/DumontDUrville', 'Antarctica/DumontDUrville'), - ('Antarctica/Macquarie', 'Antarctica/Macquarie'), - ('Antarctica/Mawson', 'Antarctica/Mawson'), - ('Antarctica/McMurdo', 'Antarctica/McMurdo'), - ('Antarctica/Palmer', 'Antarctica/Palmer'), - ('Antarctica/Rothera', 'Antarctica/Rothera'), - ('Antarctica/South_Pole', 'Antarctica/South_Pole'), - ('Antarctica/Syowa', 'Antarctica/Syowa'), - ('Antarctica/Troll', 'Antarctica/Troll'), - ('Antarctica/Vostok', 'Antarctica/Vostok'), - ('Arctic/Longyearbyen', 'Arctic/Longyearbyen'), - ('Asia/Aden', 'Asia/Aden'), ('Asia/Almaty', 'Asia/Almaty'), - ('Asia/Amman', 'Asia/Amman'), ('Asia/Anadyr', 'Asia/Anadyr'), - ('Asia/Aqtau', 'Asia/Aqtau'), ('Asia/Aqtobe', 'Asia/Aqtobe'), - ('Asia/Ashgabat', 'Asia/Ashgabat'), - ('Asia/Ashkhabad', 'Asia/Ashkhabad'), ('Asia/Atyrau', 'Asia/Atyrau'), - ('Asia/Baghdad', 'Asia/Baghdad'), ('Asia/Bahrain', 'Asia/Bahrain'), - ('Asia/Baku', 'Asia/Baku'), ('Asia/Bangkok', 'Asia/Bangkok'), - ('Asia/Barnaul', 'Asia/Barnaul'), ('Asia/Beirut', 'Asia/Beirut'), - ('Asia/Bishkek', 'Asia/Bishkek'), ('Asia/Brunei', 'Asia/Brunei'), - ('Asia/Calcutta', 'Asia/Calcutta'), ('Asia/Chita', 'Asia/Chita'), - ('Asia/Choibalsan', 'Asia/Choibalsan'), - ('Asia/Chongqing', 'Asia/Chongqing'), - ('Asia/Chungking', - 'Asia/Chungking'), ('Asia/Colombo', 'Asia/Colombo'), - ('Asia/Dacca', 'Asia/Dacca'), ('Asia/Damascus', 'Asia/Damascus'), - ('Asia/Dhaka', 'Asia/Dhaka'), ('Asia/Dili', 'Asia/Dili'), - ('Asia/Dubai', 'Asia/Dubai'), ('Asia/Dushanbe', 'Asia/Dushanbe'), - ('Asia/Famagusta', 'Asia/Famagusta'), ('Asia/Gaza', 'Asia/Gaza'), - ('Asia/Harbin', 'Asia/Harbin'), ('Asia/Hebron', 'Asia/Hebron'), - ('Asia/Ho_Chi_Minh', 'Asia/Ho_Chi_Minh'), - ('Asia/Hong_Kong', 'Asia/Hong_Kong'), ('Asia/Hovd', 'Asia/Hovd'), - ('Asia/Irkutsk', 'Asia/Irkutsk'), ('Asia/Istanbul', 'Asia/Istanbul'), - ('Asia/Jakarta', 'Asia/Jakarta'), ('Asia/Jayapura', 'Asia/Jayapura'), - ('Asia/Jerusalem', 'Asia/Jerusalem'), ('Asia/Kabul', 'Asia/Kabul'), - ('Asia/Kamchatka', 'Asia/Kamchatka'), - ('Asia/Karachi', 'Asia/Karachi'), ('Asia/Kashgar', 'Asia/Kashgar'), - ('Asia/Kathmandu', 'Asia/Kathmandu'), - ('Asia/Katmandu', - 'Asia/Katmandu'), ('Asia/Khandyga', 'Asia/Khandyga'), - ('Asia/Kolkata', 'Asia/Kolkata'), - ('Asia/Krasnoyarsk', 'Asia/Krasnoyarsk'), - ('Asia/Kuala_Lumpur', 'Asia/Kuala_Lumpur'), - ('Asia/Kuching', 'Asia/Kuching'), ('Asia/Kuwait', 'Asia/Kuwait'), - ('Asia/Macao', 'Asia/Macao'), ('Asia/Macau', 'Asia/Macau'), - ('Asia/Magadan', 'Asia/Magadan'), ('Asia/Makassar', 'Asia/Makassar'), - ('Asia/Manila', 'Asia/Manila'), ('Asia/Muscat', 'Asia/Muscat'), - ('Asia/Nicosia', 'Asia/Nicosia'), - ('Asia/Novokuznetsk', 'Asia/Novokuznetsk'), - ('Asia/Novosibirsk', 'Asia/Novosibirsk'), ('Asia/Omsk', 'Asia/Omsk'), - ('Asia/Oral', 'Asia/Oral'), ('Asia/Phnom_Penh', 'Asia/Phnom_Penh'), - ('Asia/Pontianak', 'Asia/Pontianak'), - ('Asia/Pyongyang', 'Asia/Pyongyang'), ('Asia/Qatar', 'Asia/Qatar'), - ('Asia/Qostanay', 'Asia/Qostanay'), - ('Asia/Qyzylorda', - 'Asia/Qyzylorda'), ('Asia/Rangoon', 'Asia/Rangoon'), - ('Asia/Riyadh', 'Asia/Riyadh'), ('Asia/Saigon', 'Asia/Saigon'), - ('Asia/Sakhalin', 'Asia/Sakhalin'), - ('Asia/Samarkand', 'Asia/Samarkand'), ('Asia/Seoul', 'Asia/Seoul'), - ('Asia/Shanghai', 'Asia/Shanghai'), - ('Asia/Singapore', 'Asia/Singapore'), - ('Asia/Srednekolymsk', 'Asia/Srednekolymsk'), - ('Asia/Taipei', 'Asia/Taipei'), ('Asia/Tashkent', 'Asia/Tashkent'), - ('Asia/Tbilisi', 'Asia/Tbilisi'), ('Asia/Tehran', 'Asia/Tehran'), - ('Asia/Tel_Aviv', 'Asia/Tel_Aviv'), ('Asia/Thimbu', 'Asia/Thimbu'), - ('Asia/Thimphu', 'Asia/Thimphu'), ('Asia/Tokyo', 'Asia/Tokyo'), - ('Asia/Tomsk', 'Asia/Tomsk'), - ('Asia/Ujung_Pandang', 'Asia/Ujung_Pandang'), - ('Asia/Ulaanbaatar', 'Asia/Ulaanbaatar'), - ('Asia/Ulan_Bator', - 'Asia/Ulan_Bator'), ('Asia/Urumqi', 'Asia/Urumqi'), - ('Asia/Ust-Nera', 'Asia/Ust-Nera'), - ('Asia/Vientiane', 'Asia/Vientiane'), - ('Asia/Vladivostok', 'Asia/Vladivostok'), - ('Asia/Yakutsk', 'Asia/Yakutsk'), ('Asia/Yangon', 'Asia/Yangon'), - ('Asia/Yekaterinburg', 'Asia/Yekaterinburg'), - ('Asia/Yerevan', 'Asia/Yerevan'), - ('Atlantic/Azores', 'Atlantic/Azores'), - ('Atlantic/Bermuda', 'Atlantic/Bermuda'), - ('Atlantic/Canary', 'Atlantic/Canary'), - ('Atlantic/Cape_Verde', 'Atlantic/Cape_Verde'), - ('Atlantic/Faeroe', 'Atlantic/Faeroe'), - ('Atlantic/Faroe', 'Atlantic/Faroe'), - ('Atlantic/Jan_Mayen', 'Atlantic/Jan_Mayen'), - ('Atlantic/Madeira', 'Atlantic/Madeira'), - ('Atlantic/Reykjavik', 'Atlantic/Reykjavik'), - ('Atlantic/South_Georgia', 'Atlantic/South_Georgia'), - ('Atlantic/St_Helena', 'Atlantic/St_Helena'), - ('Atlantic/Stanley', 'Atlantic/Stanley'), - ('Australia/ACT', 'Australia/ACT'), - ('Australia/Adelaide', 'Australia/Adelaide'), - ('Australia/Brisbane', 'Australia/Brisbane'), - ('Australia/Broken_Hill', 'Australia/Broken_Hill'), - ('Australia/Canberra', 'Australia/Canberra'), - ('Australia/Currie', 'Australia/Currie'), - ('Australia/Darwin', 'Australia/Darwin'), - ('Australia/Eucla', 'Australia/Eucla'), - ('Australia/Hobart', 'Australia/Hobart'), - ('Australia/LHI', 'Australia/LHI'), - ('Australia/Lindeman', 'Australia/Lindeman'), - ('Australia/Lord_Howe', 'Australia/Lord_Howe'), - ('Australia/Melbourne', 'Australia/Melbourne'), - ('Australia/NSW', 'Australia/NSW'), - ('Australia/North', 'Australia/North'), - ('Australia/Perth', 'Australia/Perth'), - ('Australia/Queensland', 'Australia/Queensland'), - ('Australia/South', 'Australia/South'), - ('Australia/Sydney', 'Australia/Sydney'), - ('Australia/Tasmania', 'Australia/Tasmania'), - ('Australia/Victoria', 'Australia/Victoria'), - ('Australia/West', 'Australia/West'), - ('Australia/Yancowinna', 'Australia/Yancowinna'), - ('Brazil/Acre', 'Brazil/Acre'), - ('Brazil/DeNoronha', 'Brazil/DeNoronha'), - ('Brazil/East', 'Brazil/East'), ('Brazil/West', 'Brazil/West'), - ('CET', 'CET'), ('CST6CDT', 'CST6CDT'), - ('Canada/Atlantic', 'Canada/Atlantic'), - ('Canada/Central', 'Canada/Central'), - ('Canada/Eastern', 'Canada/Eastern'), - ('Canada/Mountain', 'Canada/Mountain'), - ('Canada/Newfoundland', 'Canada/Newfoundland'), - ('Canada/Pacific', 'Canada/Pacific'), - ('Canada/Saskatchewan', 'Canada/Saskatchewan'), - ('Canada/Yukon', 'Canada/Yukon'), - ('Chile/Continental', 'Chile/Continental'), - ('Chile/EasterIsland', 'Chile/EasterIsland'), ('Cuba', 'Cuba'), - ('EET', 'EET'), ('EST', 'EST'), ('EST5EDT', 'EST5EDT'), - ('Egypt', 'Egypt'), ('Eire', 'Eire'), ('Etc/GMT', 'Etc/GMT'), - ('Etc/GMT+0', 'Etc/GMT+0'), ('Etc/GMT+1', 'Etc/GMT+1'), - ('Etc/GMT+10', 'Etc/GMT+10'), ('Etc/GMT+11', 'Etc/GMT+11'), - ('Etc/GMT+12', 'Etc/GMT+12'), ('Etc/GMT+2', 'Etc/GMT+2'), - ('Etc/GMT+3', 'Etc/GMT+3'), ('Etc/GMT+4', 'Etc/GMT+4'), - ('Etc/GMT+5', 'Etc/GMT+5'), ('Etc/GMT+6', 'Etc/GMT+6'), - ('Etc/GMT+7', 'Etc/GMT+7'), ('Etc/GMT+8', 'Etc/GMT+8'), - ('Etc/GMT+9', 'Etc/GMT+9'), ('Etc/GMT-0', 'Etc/GMT-0'), - ('Etc/GMT-1', 'Etc/GMT-1'), ('Etc/GMT-10', 'Etc/GMT-10'), - ('Etc/GMT-11', 'Etc/GMT-11'), ('Etc/GMT-12', 'Etc/GMT-12'), - ('Etc/GMT-13', 'Etc/GMT-13'), ('Etc/GMT-14', 'Etc/GMT-14'), - ('Etc/GMT-2', 'Etc/GMT-2'), ('Etc/GMT-3', 'Etc/GMT-3'), - ('Etc/GMT-4', 'Etc/GMT-4'), ('Etc/GMT-5', 'Etc/GMT-5'), - ('Etc/GMT-6', 'Etc/GMT-6'), ('Etc/GMT-7', 'Etc/GMT-7'), - ('Etc/GMT-8', 'Etc/GMT-8'), ('Etc/GMT-9', 'Etc/GMT-9'), - ('Etc/GMT0', 'Etc/GMT0'), ('Etc/Greenwich', 'Etc/Greenwich'), - ('Etc/UCT', 'Etc/UCT'), ('Etc/UTC', 'Etc/UTC'), - ('Etc/Universal', 'Etc/Universal'), ('Etc/Zulu', 'Etc/Zulu'), - ('Europe/Amsterdam', 'Europe/Amsterdam'), - ('Europe/Andorra', 'Europe/Andorra'), - ('Europe/Astrakhan', 'Europe/Astrakhan'), - ('Europe/Athens', 'Europe/Athens'), - ('Europe/Belfast', 'Europe/Belfast'), - ('Europe/Belgrade', 'Europe/Belgrade'), - ('Europe/Berlin', 'Europe/Berlin'), - ('Europe/Bratislava', 'Europe/Bratislava'), - ('Europe/Brussels', 'Europe/Brussels'), - ('Europe/Bucharest', 'Europe/Bucharest'), - ('Europe/Budapest', 'Europe/Budapest'), - ('Europe/Busingen', 'Europe/Busingen'), - ('Europe/Chisinau', 'Europe/Chisinau'), - ('Europe/Copenhagen', 'Europe/Copenhagen'), - ('Europe/Dublin', 'Europe/Dublin'), - ('Europe/Gibraltar', 'Europe/Gibraltar'), - ('Europe/Guernsey', 'Europe/Guernsey'), - ('Europe/Helsinki', 'Europe/Helsinki'), - ('Europe/Isle_of_Man', 'Europe/Isle_of_Man'), - ('Europe/Istanbul', 'Europe/Istanbul'), - ('Europe/Jersey', 'Europe/Jersey'), - ('Europe/Kaliningrad', 'Europe/Kaliningrad'), - ('Europe/Kiev', 'Europe/Kiev'), ('Europe/Kirov', 'Europe/Kirov'), - ('Europe/Lisbon', 'Europe/Lisbon'), - ('Europe/Ljubljana', 'Europe/Ljubljana'), - ('Europe/London', 'Europe/London'), - ('Europe/Luxembourg', 'Europe/Luxembourg'), - ('Europe/Madrid', 'Europe/Madrid'), ('Europe/Malta', 'Europe/Malta'), - ('Europe/Mariehamn', 'Europe/Mariehamn'), - ('Europe/Minsk', 'Europe/Minsk'), ('Europe/Monaco', 'Europe/Monaco'), - ('Europe/Moscow', 'Europe/Moscow'), - ('Europe/Nicosia', 'Europe/Nicosia'), ('Europe/Oslo', 'Europe/Oslo'), - ('Europe/Paris', 'Europe/Paris'), - ('Europe/Podgorica', 'Europe/Podgorica'), - ('Europe/Prague', 'Europe/Prague'), ('Europe/Riga', 'Europe/Riga'), - ('Europe/Rome', 'Europe/Rome'), ('Europe/Samara', 'Europe/Samara'), - ('Europe/San_Marino', 'Europe/San_Marino'), - ('Europe/Sarajevo', 'Europe/Sarajevo'), - ('Europe/Saratov', 'Europe/Saratov'), - ('Europe/Simferopol', 'Europe/Simferopol'), - ('Europe/Skopje', 'Europe/Skopje'), ('Europe/Sofia', 'Europe/Sofia'), - ('Europe/Stockholm', 'Europe/Stockholm'), - ('Europe/Tallinn', 'Europe/Tallinn'), - ('Europe/Tirane', 'Europe/Tirane'), - ('Europe/Tiraspol', 'Europe/Tiraspol'), - ('Europe/Ulyanovsk', 'Europe/Ulyanovsk'), - ('Europe/Uzhgorod', 'Europe/Uzhgorod'), - ('Europe/Vaduz', - 'Europe/Vaduz'), ('Europe/Vatican', 'Europe/Vatican'), - ('Europe/Vienna', 'Europe/Vienna'), - ('Europe/Vilnius', 'Europe/Vilnius'), - ('Europe/Volgograd', 'Europe/Volgograd'), - ('Europe/Warsaw', - 'Europe/Warsaw'), ('Europe/Zagreb', 'Europe/Zagreb'), - ('Europe/Zaporozhye', 'Europe/Zaporozhye'), - ('Europe/Zurich', 'Europe/Zurich'), ('GB', 'GB'), - ('GB-Eire', 'GB-Eire'), ('GMT', 'GMT'), ('GMT+0', 'GMT+0'), - ('GMT-0', 'GMT-0'), ('GMT0', 'GMT0'), ('Greenwich', 'Greenwich'), - ('HST', 'HST'), ('Hongkong', 'Hongkong'), ('Iceland', 'Iceland'), - ('Indian/Antananarivo', 'Indian/Antananarivo'), - ('Indian/Chagos', 'Indian/Chagos'), - ('Indian/Christmas', 'Indian/Christmas'), - ('Indian/Cocos', 'Indian/Cocos'), ('Indian/Comoro', 'Indian/Comoro'), - ('Indian/Kerguelen', 'Indian/Kerguelen'), - ('Indian/Mahe', - 'Indian/Mahe'), ('Indian/Maldives', 'Indian/Maldives'), - ('Indian/Mauritius', 'Indian/Mauritius'), - ('Indian/Mayotte', 'Indian/Mayotte'), - ('Indian/Reunion', 'Indian/Reunion'), ('Iran', 'Iran'), - ('Israel', 'Israel'), ('Jamaica', 'Jamaica'), ('Japan', 'Japan'), - ('Kwajalein', 'Kwajalein'), ('Libya', 'Libya'), ('MET', 'MET'), - ('MST', 'MST'), ('MST7MDT', 'MST7MDT'), - ('Mexico/BajaNorte', 'Mexico/BajaNorte'), - ('Mexico/BajaSur', 'Mexico/BajaSur'), - ('Mexico/General', 'Mexico/General'), ('NZ', 'NZ'), - ('NZ-CHAT', 'NZ-CHAT'), ('Navajo', 'Navajo'), ('PRC', 'PRC'), - ('PST8PDT', 'PST8PDT'), ('Pacific/Apia', 'Pacific/Apia'), - ('Pacific/Auckland', 'Pacific/Auckland'), - ('Pacific/Bougainville', 'Pacific/Bougainville'), - ('Pacific/Chatham', 'Pacific/Chatham'), - ('Pacific/Chuuk', 'Pacific/Chuuk'), - ('Pacific/Easter', 'Pacific/Easter'), - ('Pacific/Efate', 'Pacific/Efate'), - ('Pacific/Enderbury', 'Pacific/Enderbury'), - ('Pacific/Fakaofo', 'Pacific/Fakaofo'), - ('Pacific/Fiji', 'Pacific/Fiji'), - ('Pacific/Funafuti', 'Pacific/Funafuti'), - ('Pacific/Galapagos', 'Pacific/Galapagos'), - ('Pacific/Gambier', 'Pacific/Gambier'), - ('Pacific/Guadalcanal', 'Pacific/Guadalcanal'), - ('Pacific/Guam', 'Pacific/Guam'), - ('Pacific/Honolulu', 'Pacific/Honolulu'), - ('Pacific/Johnston', 'Pacific/Johnston'), - ('Pacific/Kiritimati', 'Pacific/Kiritimati'), - ('Pacific/Kosrae', 'Pacific/Kosrae'), - ('Pacific/Kwajalein', 'Pacific/Kwajalein'), - ('Pacific/Majuro', 'Pacific/Majuro'), - ('Pacific/Marquesas', 'Pacific/Marquesas'), - ('Pacific/Midway', 'Pacific/Midway'), - ('Pacific/Nauru', 'Pacific/Nauru'), ('Pacific/Niue', 'Pacific/Niue'), - ('Pacific/Norfolk', 'Pacific/Norfolk'), - ('Pacific/Noumea', 'Pacific/Noumea'), - ('Pacific/Pago_Pago', 'Pacific/Pago_Pago'), - ('Pacific/Palau', 'Pacific/Palau'), - ('Pacific/Pitcairn', 'Pacific/Pitcairn'), - ('Pacific/Pohnpei', 'Pacific/Pohnpei'), - ('Pacific/Ponape', 'Pacific/Ponape'), - ('Pacific/Port_Moresby', 'Pacific/Port_Moresby'), - ('Pacific/Rarotonga', 'Pacific/Rarotonga'), - ('Pacific/Saipan', 'Pacific/Saipan'), - ('Pacific/Samoa', 'Pacific/Samoa'), - ('Pacific/Tahiti', 'Pacific/Tahiti'), - ('Pacific/Tarawa', 'Pacific/Tarawa'), - ('Pacific/Tongatapu', 'Pacific/Tongatapu'), - ('Pacific/Truk', 'Pacific/Truk'), ('Pacific/Wake', 'Pacific/Wake'), - ('Pacific/Wallis', 'Pacific/Wallis'), ('Pacific/Yap', 'Pacific/Yap'), - ('Poland', 'Poland'), ('Portugal', 'Portugal'), ('ROC', 'ROC'), - ('ROK', 'ROK'), ('Singapore', 'Singapore'), ('Turkey', 'Turkey'), - ('UCT', 'UCT'), ('US/Alaska', 'US/Alaska'), - ('US/Aleutian', 'US/Aleutian'), ('US/Arizona', 'US/Arizona'), - ('US/Central', 'US/Central'), ('US/East-Indiana', 'US/East-Indiana'), - ('US/Eastern', 'US/Eastern'), ('US/Hawaii', 'US/Hawaii'), - ('US/Indiana-Starke', 'US/Indiana-Starke'), - ('US/Michigan', 'US/Michigan'), ('US/Mountain', 'US/Mountain'), - ('US/Pacific', 'US/Pacific'), ('US/Samoa', - 'US/Samoa'), ('UTC', 'UTC'), - ('Universal', 'Universal'), ('W-SU', 'W-SU'), ('WET', 'WET'), - ('Zulu', 'Zulu') + ("Africa/Abidjan", "Africa/Abidjan"), + ("Africa/Accra", "Africa/Accra"), + ("Africa/Addis_Ababa", "Africa/Addis_Ababa"), + ("Africa/Algiers", "Africa/Algiers"), + ("Africa/Asmara", "Africa/Asmara"), + ("Africa/Asmera", "Africa/Asmera"), + ("Africa/Bamako", "Africa/Bamako"), + ("Africa/Bangui", "Africa/Bangui"), + ("Africa/Banjul", "Africa/Banjul"), + ("Africa/Bissau", "Africa/Bissau"), + ("Africa/Blantyre", "Africa/Blantyre"), + ("Africa/Brazzaville", "Africa/Brazzaville"), + ("Africa/Bujumbura", "Africa/Bujumbura"), + ("Africa/Cairo", "Africa/Cairo"), + ("Africa/Casablanca", "Africa/Casablanca"), + ("Africa/Ceuta", "Africa/Ceuta"), + ("Africa/Conakry", "Africa/Conakry"), + ("Africa/Dakar", "Africa/Dakar"), + ("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"), + ("Africa/Djibouti", "Africa/Djibouti"), + ("Africa/Douala", "Africa/Douala"), + ("Africa/El_Aaiun", "Africa/El_Aaiun"), + ("Africa/Freetown", "Africa/Freetown"), + ("Africa/Gaborone", "Africa/Gaborone"), + ("Africa/Harare", "Africa/Harare"), + ("Africa/Johannesburg", "Africa/Johannesburg"), + ("Africa/Juba", "Africa/Juba"), + ("Africa/Kampala", "Africa/Kampala"), + ("Africa/Khartoum", "Africa/Khartoum"), + ("Africa/Kigali", "Africa/Kigali"), + ("Africa/Kinshasa", "Africa/Kinshasa"), + ("Africa/Lagos", "Africa/Lagos"), + ("Africa/Libreville", "Africa/Libreville"), + ("Africa/Lome", "Africa/Lome"), + ("Africa/Luanda", "Africa/Luanda"), + ("Africa/Lubumbashi", "Africa/Lubumbashi"), + ("Africa/Lusaka", "Africa/Lusaka"), + ("Africa/Malabo", "Africa/Malabo"), + ("Africa/Maputo", "Africa/Maputo"), + ("Africa/Maseru", "Africa/Maseru"), + ("Africa/Mbabane", "Africa/Mbabane"), + ("Africa/Mogadishu", "Africa/Mogadishu"), + ("Africa/Monrovia", "Africa/Monrovia"), + ("Africa/Nairobi", "Africa/Nairobi"), + ("Africa/Ndjamena", "Africa/Ndjamena"), + ("Africa/Niamey", "Africa/Niamey"), + ("Africa/Nouakchott", "Africa/Nouakchott"), + ("Africa/Ouagadougou", "Africa/Ouagadougou"), + ("Africa/Porto-Novo", "Africa/Porto-Novo"), + ("Africa/Sao_Tome", "Africa/Sao_Tome"), + ("Africa/Timbuktu", "Africa/Timbuktu"), + ("Africa/Tripoli", "Africa/Tripoli"), + ("Africa/Tunis", "Africa/Tunis"), + ("Africa/Windhoek", "Africa/Windhoek"), + ("America/Adak", "America/Adak"), + ("America/Anchorage", "America/Anchorage"), + ("America/Anguilla", "America/Anguilla"), + ("America/Antigua", "America/Antigua"), + ("America/Araguaina", "America/Araguaina"), + ("America/Argentina/Buenos_Aires", "America/Argentina/Buenos_Aires"), + ("America/Argentina/Catamarca", "America/Argentina/Catamarca"), + ("America/Argentina/ComodRivadavia", "America/Argentina/ComodRivadavia"), + ("America/Argentina/Cordoba", "America/Argentina/Cordoba"), + ("America/Argentina/Jujuy", "America/Argentina/Jujuy"), + ("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"), + ("America/Argentina/Mendoza", "America/Argentina/Mendoza"), + ("America/Argentina/Rio_Gallegos", "America/Argentina/Rio_Gallegos"), + ("America/Argentina/Salta", "America/Argentina/Salta"), + ("America/Argentina/San_Juan", "America/Argentina/San_Juan"), + ("America/Argentina/San_Luis", "America/Argentina/San_Luis"), + ("America/Argentina/Tucuman", "America/Argentina/Tucuman"), + ("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"), + ("America/Aruba", "America/Aruba"), + ("America/Asuncion", "America/Asuncion"), + ("America/Atikokan", "America/Atikokan"), + ("America/Atka", "America/Atka"), + ("America/Bahia", "America/Bahia"), + ("America/Bahia_Banderas", "America/Bahia_Banderas"), + ("America/Barbados", "America/Barbados"), + ("America/Belem", "America/Belem"), + ("America/Belize", "America/Belize"), + ("America/Blanc-Sablon", "America/Blanc-Sablon"), + ("America/Boa_Vista", "America/Boa_Vista"), + ("America/Bogota", "America/Bogota"), + ("America/Boise", "America/Boise"), + ("America/Buenos_Aires", "America/Buenos_Aires"), + ("America/Cambridge_Bay", "America/Cambridge_Bay"), + ("America/Campo_Grande", "America/Campo_Grande"), + ("America/Cancun", "America/Cancun"), + ("America/Caracas", "America/Caracas"), + ("America/Catamarca", "America/Catamarca"), + ("America/Cayenne", "America/Cayenne"), + ("America/Cayman", "America/Cayman"), + ("America/Chicago", "America/Chicago"), + ("America/Chihuahua", "America/Chihuahua"), + ("America/Coral_Harbour", "America/Coral_Harbour"), + ("America/Cordoba", "America/Cordoba"), + ("America/Costa_Rica", "America/Costa_Rica"), + ("America/Creston", "America/Creston"), + ("America/Cuiaba", "America/Cuiaba"), + ("America/Curacao", "America/Curacao"), + ("America/Danmarkshavn", "America/Danmarkshavn"), + ("America/Dawson", "America/Dawson"), + ("America/Dawson_Creek", "America/Dawson_Creek"), + ("America/Denver", "America/Denver"), + ("America/Detroit", "America/Detroit"), + ("America/Dominica", "America/Dominica"), + ("America/Edmonton", "America/Edmonton"), + ("America/Eirunepe", "America/Eirunepe"), + ("America/El_Salvador", "America/El_Salvador"), + ("America/Ensenada", "America/Ensenada"), + ("America/Fort_Nelson", "America/Fort_Nelson"), + ("America/Fort_Wayne", "America/Fort_Wayne"), + ("America/Fortaleza", "America/Fortaleza"), + ("America/Glace_Bay", "America/Glace_Bay"), + ("America/Godthab", "America/Godthab"), + ("America/Goose_Bay", "America/Goose_Bay"), + ("America/Grand_Turk", "America/Grand_Turk"), + ("America/Grenada", "America/Grenada"), + ("America/Guadeloupe", "America/Guadeloupe"), + ("America/Guatemala", "America/Guatemala"), + ("America/Guayaquil", "America/Guayaquil"), + ("America/Guyana", "America/Guyana"), + ("America/Halifax", "America/Halifax"), + ("America/Havana", "America/Havana"), + ("America/Hermosillo", "America/Hermosillo"), + ("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"), + ("America/Indiana/Knox", "America/Indiana/Knox"), + ("America/Indiana/Marengo", "America/Indiana/Marengo"), + ("America/Indiana/Petersburg", "America/Indiana/Petersburg"), + ("America/Indiana/Tell_City", "America/Indiana/Tell_City"), + ("America/Indiana/Vevay", "America/Indiana/Vevay"), + ("America/Indiana/Vincennes", "America/Indiana/Vincennes"), + ("America/Indiana/Winamac", "America/Indiana/Winamac"), + ("America/Indianapolis", "America/Indianapolis"), + ("America/Inuvik", "America/Inuvik"), + ("America/Iqaluit", "America/Iqaluit"), + ("America/Jamaica", "America/Jamaica"), + ("America/Jujuy", "America/Jujuy"), + ("America/Juneau", "America/Juneau"), + ("America/Kentucky/Louisville", "America/Kentucky/Louisville"), + ("America/Kentucky/Monticello", "America/Kentucky/Monticello"), + ("America/Knox_IN", "America/Knox_IN"), + ("America/Kralendijk", "America/Kralendijk"), + ("America/La_Paz", "America/La_Paz"), + ("America/Lima", "America/Lima"), + ("America/Los_Angeles", "America/Los_Angeles"), + ("America/Louisville", "America/Louisville"), + ("America/Lower_Princes", "America/Lower_Princes"), + ("America/Maceio", "America/Maceio"), + ("America/Managua", "America/Managua"), + ("America/Manaus", "America/Manaus"), + ("America/Marigot", "America/Marigot"), + ("America/Martinique", "America/Martinique"), + ("America/Matamoros", "America/Matamoros"), + ("America/Mazatlan", "America/Mazatlan"), + ("America/Mendoza", "America/Mendoza"), + ("America/Menominee", "America/Menominee"), + ("America/Merida", "America/Merida"), + ("America/Metlakatla", "America/Metlakatla"), + ("America/Mexico_City", "America/Mexico_City"), + ("America/Miquelon", "America/Miquelon"), + ("America/Moncton", "America/Moncton"), + ("America/Monterrey", "America/Monterrey"), + ("America/Montevideo", "America/Montevideo"), + ("America/Montreal", "America/Montreal"), + ("America/Montserrat", "America/Montserrat"), + ("America/Nassau", "America/Nassau"), + ("America/New_York", "America/New_York"), + ("America/Nipigon", "America/Nipigon"), + ("America/Nome", "America/Nome"), + ("America/Noronha", "America/Noronha"), + ("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"), + ("America/North_Dakota/Center", "America/North_Dakota/Center"), + ("America/North_Dakota/New_Salem", "America/North_Dakota/New_Salem"), + ("America/Ojinaga", "America/Ojinaga"), + ("America/Panama", "America/Panama"), + ("America/Pangnirtung", "America/Pangnirtung"), + ("America/Paramaribo", "America/Paramaribo"), + ("America/Phoenix", "America/Phoenix"), + ("America/Port-au-Prince", "America/Port-au-Prince"), + ("America/Port_of_Spain", "America/Port_of_Spain"), + ("America/Porto_Acre", "America/Porto_Acre"), + ("America/Porto_Velho", "America/Porto_Velho"), + ("America/Puerto_Rico", "America/Puerto_Rico"), + ("America/Punta_Arenas", "America/Punta_Arenas"), + ("America/Rainy_River", "America/Rainy_River"), + ("America/Rankin_Inlet", "America/Rankin_Inlet"), + ("America/Recife", "America/Recife"), + ("America/Regina", "America/Regina"), + ("America/Resolute", "America/Resolute"), + ("America/Rio_Branco", "America/Rio_Branco"), + ("America/Rosario", "America/Rosario"), + ("America/Santa_Isabel", "America/Santa_Isabel"), + ("America/Santarem", "America/Santarem"), + ("America/Santiago", "America/Santiago"), + ("America/Santo_Domingo", "America/Santo_Domingo"), + ("America/Sao_Paulo", "America/Sao_Paulo"), + ("America/Scoresbysund", "America/Scoresbysund"), + ("America/Shiprock", "America/Shiprock"), + ("America/Sitka", "America/Sitka"), + ("America/St_Barthelemy", "America/St_Barthelemy"), + ("America/St_Johns", "America/St_Johns"), + ("America/St_Kitts", "America/St_Kitts"), + ("America/St_Lucia", "America/St_Lucia"), + ("America/St_Thomas", "America/St_Thomas"), + ("America/St_Vincent", "America/St_Vincent"), + ("America/Swift_Current", "America/Swift_Current"), + ("America/Tegucigalpa", "America/Tegucigalpa"), + ("America/Thule", "America/Thule"), + ("America/Thunder_Bay", "America/Thunder_Bay"), + ("America/Tijuana", "America/Tijuana"), + ("America/Toronto", "America/Toronto"), + ("America/Tortola", "America/Tortola"), + ("America/Vancouver", "America/Vancouver"), + ("America/Virgin", "America/Virgin"), + ("America/Whitehorse", "America/Whitehorse"), + ("America/Winnipeg", "America/Winnipeg"), + ("America/Yakutat", "America/Yakutat"), + ("America/Yellowknife", "America/Yellowknife"), + ("Antarctica/Casey", "Antarctica/Casey"), + ("Antarctica/Davis", "Antarctica/Davis"), + ("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"), + ("Antarctica/Macquarie", "Antarctica/Macquarie"), + ("Antarctica/Mawson", "Antarctica/Mawson"), + ("Antarctica/McMurdo", "Antarctica/McMurdo"), + ("Antarctica/Palmer", "Antarctica/Palmer"), + ("Antarctica/Rothera", "Antarctica/Rothera"), + ("Antarctica/South_Pole", "Antarctica/South_Pole"), + ("Antarctica/Syowa", "Antarctica/Syowa"), + ("Antarctica/Troll", "Antarctica/Troll"), + ("Antarctica/Vostok", "Antarctica/Vostok"), + ("Arctic/Longyearbyen", "Arctic/Longyearbyen"), + ("Asia/Aden", "Asia/Aden"), + ("Asia/Almaty", "Asia/Almaty"), + ("Asia/Amman", "Asia/Amman"), + ("Asia/Anadyr", "Asia/Anadyr"), + ("Asia/Aqtau", "Asia/Aqtau"), + ("Asia/Aqtobe", "Asia/Aqtobe"), + ("Asia/Ashgabat", "Asia/Ashgabat"), + ("Asia/Ashkhabad", "Asia/Ashkhabad"), + ("Asia/Atyrau", "Asia/Atyrau"), + ("Asia/Baghdad", "Asia/Baghdad"), + ("Asia/Bahrain", "Asia/Bahrain"), + ("Asia/Baku", "Asia/Baku"), + ("Asia/Bangkok", "Asia/Bangkok"), + ("Asia/Barnaul", "Asia/Barnaul"), + ("Asia/Beirut", "Asia/Beirut"), + ("Asia/Bishkek", "Asia/Bishkek"), + ("Asia/Brunei", "Asia/Brunei"), + ("Asia/Calcutta", "Asia/Calcutta"), + ("Asia/Chita", "Asia/Chita"), + ("Asia/Choibalsan", "Asia/Choibalsan"), + ("Asia/Chongqing", "Asia/Chongqing"), + ("Asia/Chungking", "Asia/Chungking"), + ("Asia/Colombo", "Asia/Colombo"), + ("Asia/Dacca", "Asia/Dacca"), + ("Asia/Damascus", "Asia/Damascus"), + ("Asia/Dhaka", "Asia/Dhaka"), + ("Asia/Dili", "Asia/Dili"), + ("Asia/Dubai", "Asia/Dubai"), + ("Asia/Dushanbe", "Asia/Dushanbe"), + ("Asia/Famagusta", "Asia/Famagusta"), + ("Asia/Gaza", "Asia/Gaza"), + ("Asia/Harbin", "Asia/Harbin"), + ("Asia/Hebron", "Asia/Hebron"), + ("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"), + ("Asia/Hong_Kong", "Asia/Hong_Kong"), + ("Asia/Hovd", "Asia/Hovd"), + ("Asia/Irkutsk", "Asia/Irkutsk"), + ("Asia/Istanbul", "Asia/Istanbul"), + ("Asia/Jakarta", "Asia/Jakarta"), + ("Asia/Jayapura", "Asia/Jayapura"), + ("Asia/Jerusalem", "Asia/Jerusalem"), + ("Asia/Kabul", "Asia/Kabul"), + ("Asia/Kamchatka", "Asia/Kamchatka"), + ("Asia/Karachi", "Asia/Karachi"), + ("Asia/Kashgar", "Asia/Kashgar"), + ("Asia/Kathmandu", "Asia/Kathmandu"), + ("Asia/Katmandu", "Asia/Katmandu"), + ("Asia/Khandyga", "Asia/Khandyga"), + ("Asia/Kolkata", "Asia/Kolkata"), + ("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"), + ("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"), + ("Asia/Kuching", "Asia/Kuching"), + ("Asia/Kuwait", "Asia/Kuwait"), + ("Asia/Macao", "Asia/Macao"), + ("Asia/Macau", "Asia/Macau"), + ("Asia/Magadan", "Asia/Magadan"), + ("Asia/Makassar", "Asia/Makassar"), + ("Asia/Manila", "Asia/Manila"), + ("Asia/Muscat", "Asia/Muscat"), + ("Asia/Nicosia", "Asia/Nicosia"), + ("Asia/Novokuznetsk", "Asia/Novokuznetsk"), + ("Asia/Novosibirsk", "Asia/Novosibirsk"), + ("Asia/Omsk", "Asia/Omsk"), + ("Asia/Oral", "Asia/Oral"), + ("Asia/Phnom_Penh", "Asia/Phnom_Penh"), + ("Asia/Pontianak", "Asia/Pontianak"), + ("Asia/Pyongyang", "Asia/Pyongyang"), + ("Asia/Qatar", "Asia/Qatar"), + ("Asia/Qostanay", "Asia/Qostanay"), + ("Asia/Qyzylorda", "Asia/Qyzylorda"), + ("Asia/Rangoon", "Asia/Rangoon"), + ("Asia/Riyadh", "Asia/Riyadh"), + ("Asia/Saigon", "Asia/Saigon"), + ("Asia/Sakhalin", "Asia/Sakhalin"), + ("Asia/Samarkand", "Asia/Samarkand"), + ("Asia/Seoul", "Asia/Seoul"), + ("Asia/Shanghai", "Asia/Shanghai"), + ("Asia/Singapore", "Asia/Singapore"), + ("Asia/Srednekolymsk", "Asia/Srednekolymsk"), + ("Asia/Taipei", "Asia/Taipei"), + ("Asia/Tashkent", "Asia/Tashkent"), + ("Asia/Tbilisi", "Asia/Tbilisi"), + ("Asia/Tehran", "Asia/Tehran"), + ("Asia/Tel_Aviv", "Asia/Tel_Aviv"), + ("Asia/Thimbu", "Asia/Thimbu"), + ("Asia/Thimphu", "Asia/Thimphu"), + ("Asia/Tokyo", "Asia/Tokyo"), + ("Asia/Tomsk", "Asia/Tomsk"), + ("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"), + ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"), + ("Asia/Ulan_Bator", "Asia/Ulan_Bator"), + ("Asia/Urumqi", "Asia/Urumqi"), + ("Asia/Ust-Nera", "Asia/Ust-Nera"), + ("Asia/Vientiane", "Asia/Vientiane"), + ("Asia/Vladivostok", "Asia/Vladivostok"), + ("Asia/Yakutsk", "Asia/Yakutsk"), + ("Asia/Yangon", "Asia/Yangon"), + ("Asia/Yekaterinburg", "Asia/Yekaterinburg"), + ("Asia/Yerevan", "Asia/Yerevan"), + ("Atlantic/Azores", "Atlantic/Azores"), + ("Atlantic/Bermuda", "Atlantic/Bermuda"), + ("Atlantic/Canary", "Atlantic/Canary"), + ("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"), + ("Atlantic/Faeroe", "Atlantic/Faeroe"), + ("Atlantic/Faroe", "Atlantic/Faroe"), + ("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"), + ("Atlantic/Madeira", "Atlantic/Madeira"), + ("Atlantic/Reykjavik", "Atlantic/Reykjavik"), + ("Atlantic/South_Georgia", "Atlantic/South_Georgia"), + ("Atlantic/St_Helena", "Atlantic/St_Helena"), + ("Atlantic/Stanley", "Atlantic/Stanley"), + ("Australia/ACT", "Australia/ACT"), + ("Australia/Adelaide", "Australia/Adelaide"), + ("Australia/Brisbane", "Australia/Brisbane"), + ("Australia/Broken_Hill", "Australia/Broken_Hill"), + ("Australia/Canberra", "Australia/Canberra"), + ("Australia/Currie", "Australia/Currie"), + ("Australia/Darwin", "Australia/Darwin"), + ("Australia/Eucla", "Australia/Eucla"), + ("Australia/Hobart", "Australia/Hobart"), + ("Australia/LHI", "Australia/LHI"), + ("Australia/Lindeman", "Australia/Lindeman"), + ("Australia/Lord_Howe", "Australia/Lord_Howe"), + ("Australia/Melbourne", "Australia/Melbourne"), + ("Australia/NSW", "Australia/NSW"), + ("Australia/North", "Australia/North"), + ("Australia/Perth", "Australia/Perth"), + ("Australia/Queensland", "Australia/Queensland"), + ("Australia/South", "Australia/South"), + ("Australia/Sydney", "Australia/Sydney"), + ("Australia/Tasmania", "Australia/Tasmania"), + ("Australia/Victoria", "Australia/Victoria"), + ("Australia/West", "Australia/West"), + ("Australia/Yancowinna", "Australia/Yancowinna"), + ("Brazil/Acre", "Brazil/Acre"), + ("Brazil/DeNoronha", "Brazil/DeNoronha"), + ("Brazil/East", "Brazil/East"), + ("Brazil/West", "Brazil/West"), + ("CET", "CET"), + ("CST6CDT", "CST6CDT"), + ("Canada/Atlantic", "Canada/Atlantic"), + ("Canada/Central", "Canada/Central"), + ("Canada/Eastern", "Canada/Eastern"), + ("Canada/Mountain", "Canada/Mountain"), + ("Canada/Newfoundland", "Canada/Newfoundland"), + ("Canada/Pacific", "Canada/Pacific"), + ("Canada/Saskatchewan", "Canada/Saskatchewan"), + ("Canada/Yukon", "Canada/Yukon"), + ("Chile/Continental", "Chile/Continental"), + ("Chile/EasterIsland", "Chile/EasterIsland"), + ("Cuba", "Cuba"), + ("EET", "EET"), + ("EST", "EST"), + ("EST5EDT", "EST5EDT"), + ("Egypt", "Egypt"), + ("Eire", "Eire"), + ("Etc/GMT", "Etc/GMT"), + ("Etc/GMT+0", "Etc/GMT+0"), + ("Etc/GMT+1", "Etc/GMT+1"), + ("Etc/GMT+10", "Etc/GMT+10"), + ("Etc/GMT+11", "Etc/GMT+11"), + ("Etc/GMT+12", "Etc/GMT+12"), + ("Etc/GMT+2", "Etc/GMT+2"), + ("Etc/GMT+3", "Etc/GMT+3"), + ("Etc/GMT+4", "Etc/GMT+4"), + ("Etc/GMT+5", "Etc/GMT+5"), + ("Etc/GMT+6", "Etc/GMT+6"), + ("Etc/GMT+7", "Etc/GMT+7"), + ("Etc/GMT+8", "Etc/GMT+8"), + ("Etc/GMT+9", "Etc/GMT+9"), + ("Etc/GMT-0", "Etc/GMT-0"), + ("Etc/GMT-1", "Etc/GMT-1"), + ("Etc/GMT-10", "Etc/GMT-10"), + ("Etc/GMT-11", "Etc/GMT-11"), + ("Etc/GMT-12", "Etc/GMT-12"), + ("Etc/GMT-13", "Etc/GMT-13"), + ("Etc/GMT-14", "Etc/GMT-14"), + ("Etc/GMT-2", "Etc/GMT-2"), + ("Etc/GMT-3", "Etc/GMT-3"), + ("Etc/GMT-4", "Etc/GMT-4"), + ("Etc/GMT-5", "Etc/GMT-5"), + ("Etc/GMT-6", "Etc/GMT-6"), + ("Etc/GMT-7", "Etc/GMT-7"), + ("Etc/GMT-8", "Etc/GMT-8"), + ("Etc/GMT-9", "Etc/GMT-9"), + ("Etc/GMT0", "Etc/GMT0"), + ("Etc/Greenwich", "Etc/Greenwich"), + ("Etc/UCT", "Etc/UCT"), + ("Etc/UTC", "Etc/UTC"), + ("Etc/Universal", "Etc/Universal"), + ("Etc/Zulu", "Etc/Zulu"), + ("Europe/Amsterdam", "Europe/Amsterdam"), + ("Europe/Andorra", "Europe/Andorra"), + ("Europe/Astrakhan", "Europe/Astrakhan"), + ("Europe/Athens", "Europe/Athens"), + ("Europe/Belfast", "Europe/Belfast"), + ("Europe/Belgrade", "Europe/Belgrade"), + ("Europe/Berlin", "Europe/Berlin"), + ("Europe/Bratislava", "Europe/Bratislava"), + ("Europe/Brussels", "Europe/Brussels"), + ("Europe/Bucharest", "Europe/Bucharest"), + ("Europe/Budapest", "Europe/Budapest"), + ("Europe/Busingen", "Europe/Busingen"), + ("Europe/Chisinau", "Europe/Chisinau"), + ("Europe/Copenhagen", "Europe/Copenhagen"), + ("Europe/Dublin", "Europe/Dublin"), + ("Europe/Gibraltar", "Europe/Gibraltar"), + ("Europe/Guernsey", "Europe/Guernsey"), + ("Europe/Helsinki", "Europe/Helsinki"), + ("Europe/Isle_of_Man", "Europe/Isle_of_Man"), + ("Europe/Istanbul", "Europe/Istanbul"), + ("Europe/Jersey", "Europe/Jersey"), + ("Europe/Kaliningrad", "Europe/Kaliningrad"), + ("Europe/Kiev", "Europe/Kiev"), + ("Europe/Kirov", "Europe/Kirov"), + ("Europe/Lisbon", "Europe/Lisbon"), + ("Europe/Ljubljana", "Europe/Ljubljana"), + ("Europe/London", "Europe/London"), + ("Europe/Luxembourg", "Europe/Luxembourg"), + ("Europe/Madrid", "Europe/Madrid"), + ("Europe/Malta", "Europe/Malta"), + ("Europe/Mariehamn", "Europe/Mariehamn"), + ("Europe/Minsk", "Europe/Minsk"), + ("Europe/Monaco", "Europe/Monaco"), + ("Europe/Moscow", "Europe/Moscow"), + ("Europe/Nicosia", "Europe/Nicosia"), + ("Europe/Oslo", "Europe/Oslo"), + ("Europe/Paris", "Europe/Paris"), + ("Europe/Podgorica", "Europe/Podgorica"), + ("Europe/Prague", "Europe/Prague"), + ("Europe/Riga", "Europe/Riga"), + ("Europe/Rome", "Europe/Rome"), + ("Europe/Samara", "Europe/Samara"), + ("Europe/San_Marino", "Europe/San_Marino"), + ("Europe/Sarajevo", "Europe/Sarajevo"), + ("Europe/Saratov", "Europe/Saratov"), + ("Europe/Simferopol", "Europe/Simferopol"), + ("Europe/Skopje", "Europe/Skopje"), + ("Europe/Sofia", "Europe/Sofia"), + ("Europe/Stockholm", "Europe/Stockholm"), + ("Europe/Tallinn", "Europe/Tallinn"), + ("Europe/Tirane", "Europe/Tirane"), + ("Europe/Tiraspol", "Europe/Tiraspol"), + ("Europe/Ulyanovsk", "Europe/Ulyanovsk"), + ("Europe/Uzhgorod", "Europe/Uzhgorod"), + ("Europe/Vaduz", "Europe/Vaduz"), + ("Europe/Vatican", "Europe/Vatican"), + ("Europe/Vienna", "Europe/Vienna"), + ("Europe/Vilnius", "Europe/Vilnius"), + ("Europe/Volgograd", "Europe/Volgograd"), + ("Europe/Warsaw", "Europe/Warsaw"), + ("Europe/Zagreb", "Europe/Zagreb"), + ("Europe/Zaporozhye", "Europe/Zaporozhye"), + ("Europe/Zurich", "Europe/Zurich"), + ("GB", "GB"), + ("GB-Eire", "GB-Eire"), + ("GMT", "GMT"), + ("GMT+0", "GMT+0"), + ("GMT-0", "GMT-0"), + ("GMT0", "GMT0"), + ("Greenwich", "Greenwich"), + ("HST", "HST"), + ("Hongkong", "Hongkong"), + ("Iceland", "Iceland"), + ("Indian/Antananarivo", "Indian/Antananarivo"), + ("Indian/Chagos", "Indian/Chagos"), + ("Indian/Christmas", "Indian/Christmas"), + ("Indian/Cocos", "Indian/Cocos"), + ("Indian/Comoro", "Indian/Comoro"), + ("Indian/Kerguelen", "Indian/Kerguelen"), + ("Indian/Mahe", "Indian/Mahe"), + ("Indian/Maldives", "Indian/Maldives"), + ("Indian/Mauritius", "Indian/Mauritius"), + ("Indian/Mayotte", "Indian/Mayotte"), + ("Indian/Reunion", "Indian/Reunion"), + ("Iran", "Iran"), + ("Israel", "Israel"), + ("Jamaica", "Jamaica"), + ("Japan", "Japan"), + ("Kwajalein", "Kwajalein"), + ("Libya", "Libya"), + ("MET", "MET"), + ("MST", "MST"), + ("MST7MDT", "MST7MDT"), + ("Mexico/BajaNorte", "Mexico/BajaNorte"), + ("Mexico/BajaSur", "Mexico/BajaSur"), + ("Mexico/General", "Mexico/General"), + ("NZ", "NZ"), + ("NZ-CHAT", "NZ-CHAT"), + ("Navajo", "Navajo"), + ("PRC", "PRC"), + ("PST8PDT", "PST8PDT"), + ("Pacific/Apia", "Pacific/Apia"), + ("Pacific/Auckland", "Pacific/Auckland"), + ("Pacific/Bougainville", "Pacific/Bougainville"), + ("Pacific/Chatham", "Pacific/Chatham"), + ("Pacific/Chuuk", "Pacific/Chuuk"), + ("Pacific/Easter", "Pacific/Easter"), + ("Pacific/Efate", "Pacific/Efate"), + ("Pacific/Enderbury", "Pacific/Enderbury"), + ("Pacific/Fakaofo", "Pacific/Fakaofo"), + ("Pacific/Fiji", "Pacific/Fiji"), + ("Pacific/Funafuti", "Pacific/Funafuti"), + ("Pacific/Galapagos", "Pacific/Galapagos"), + ("Pacific/Gambier", "Pacific/Gambier"), + ("Pacific/Guadalcanal", "Pacific/Guadalcanal"), + ("Pacific/Guam", "Pacific/Guam"), + ("Pacific/Honolulu", "Pacific/Honolulu"), + ("Pacific/Johnston", "Pacific/Johnston"), + ("Pacific/Kiritimati", "Pacific/Kiritimati"), + ("Pacific/Kosrae", "Pacific/Kosrae"), + ("Pacific/Kwajalein", "Pacific/Kwajalein"), + ("Pacific/Majuro", "Pacific/Majuro"), + ("Pacific/Marquesas", "Pacific/Marquesas"), + ("Pacific/Midway", "Pacific/Midway"), + ("Pacific/Nauru", "Pacific/Nauru"), + ("Pacific/Niue", "Pacific/Niue"), + ("Pacific/Norfolk", "Pacific/Norfolk"), + ("Pacific/Noumea", "Pacific/Noumea"), + ("Pacific/Pago_Pago", "Pacific/Pago_Pago"), + ("Pacific/Palau", "Pacific/Palau"), + ("Pacific/Pitcairn", "Pacific/Pitcairn"), + ("Pacific/Pohnpei", "Pacific/Pohnpei"), + ("Pacific/Ponape", "Pacific/Ponape"), + ("Pacific/Port_Moresby", "Pacific/Port_Moresby"), + ("Pacific/Rarotonga", "Pacific/Rarotonga"), + ("Pacific/Saipan", "Pacific/Saipan"), + ("Pacific/Samoa", "Pacific/Samoa"), + ("Pacific/Tahiti", "Pacific/Tahiti"), + ("Pacific/Tarawa", "Pacific/Tarawa"), + ("Pacific/Tongatapu", "Pacific/Tongatapu"), + ("Pacific/Truk", "Pacific/Truk"), + ("Pacific/Wake", "Pacific/Wake"), + ("Pacific/Wallis", "Pacific/Wallis"), + ("Pacific/Yap", "Pacific/Yap"), + ("Poland", "Poland"), + ("Portugal", "Portugal"), + ("ROC", "ROC"), + ("ROK", "ROK"), + ("Singapore", "Singapore"), + ("Turkey", "Turkey"), + ("UCT", "UCT"), + ("US/Alaska", "US/Alaska"), + ("US/Aleutian", "US/Aleutian"), + ("US/Arizona", "US/Arizona"), + ("US/Central", "US/Central"), + ("US/East-Indiana", "US/East-Indiana"), + ("US/Eastern", "US/Eastern"), + ("US/Hawaii", "US/Hawaii"), + ("US/Indiana-Starke", "US/Indiana-Starke"), + ("US/Michigan", "US/Michigan"), + ("US/Mountain", "US/Mountain"), + ("US/Pacific", "US/Pacific"), + ("US/Samoa", "US/Samoa"), + ("UTC", "UTC"), + ("Universal", "Universal"), + ("W-SU", "W-SU"), + ("WET", "WET"), + ("Zulu", "Zulu"), ], - default='UTC', - max_length=100 + default="UTC", + max_length=100, ), - ), + ) ] diff --git a/src/newsreader/news/collection/migrations/0005_auto_20190521_1941.py b/src/newsreader/news/collection/migrations/0005_auto_20190521_1941.py index e9ab3d4..8e3a53f 100644 --- a/src/newsreader/news/collection/migrations/0005_auto_20190521_1941.py +++ b/src/newsreader/news/collection/migrations/0005_auto_20190521_1941.py @@ -5,20 +5,18 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('collection', '0004_collectionrule_timezone'), - ] + dependencies = [("collection", "0004_collectionrule_timezone")] operations = [ migrations.AddField( - model_name='collectionrule', - name='favicon', - field=models.ImageField(blank=True, null=True, upload_to=''), + model_name="collectionrule", + name="favicon", + field=models.ImageField(blank=True, null=True, upload_to=""), ), migrations.AddField( - model_name='collectionrule', - name='source', - field=models.CharField(default='source', max_length=100), + model_name="collectionrule", + name="source", + field=models.CharField(default="source", max_length=100), preserve_default=False, ), ] diff --git a/src/newsreader/news/collection/migrations/0006_collectionrule_error.py b/src/newsreader/news/collection/migrations/0006_collectionrule_error.py index 78843b1..b70b638 100644 --- a/src/newsreader/news/collection/migrations/0006_collectionrule_error.py +++ b/src/newsreader/news/collection/migrations/0006_collectionrule_error.py @@ -5,14 +5,12 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('collection', '0005_auto_20190521_1941'), - ] + dependencies = [("collection", "0005_auto_20190521_1941")] operations = [ migrations.AddField( - model_name='collectionrule', - name='error', + model_name="collectionrule", + name="error", field=models.CharField(blank=True, max_length=255, null=True), - ), + ) ] diff --git a/src/newsreader/news/collection/models.py b/src/newsreader/news/collection/models.py index 1de9849..03768ac 100644 --- a/src/newsreader/news/collection/models.py +++ b/src/newsreader/news/collection/models.py @@ -1,9 +1,9 @@ -import pytz - from django.conf import settings from django.db import models from django.utils.translation import gettext as _ +import pytz + class CollectionRule(models.Model): name = models.CharField(max_length=100) diff --git a/src/newsreader/news/collection/tests/favicon/builder/mocks.py b/src/newsreader/news/collection/tests/favicon/builder/mocks.py index ce02475..8011472 100644 --- a/src/newsreader/news/collection/tests/favicon/builder/mocks.py +++ b/src/newsreader/news/collection/tests/favicon/builder/mocks.py @@ -1,5 +1,6 @@ from bs4 import BeautifulSoup + simple_mock = BeautifulSoup( """ diff --git a/src/newsreader/news/collection/tests/favicon/builder/tests.py b/src/newsreader/news/collection/tests/favicon/builder/tests.py index d08fce7..c8bd14c 100644 --- a/src/newsreader/news/collection/tests/favicon/builder/tests.py +++ b/src/newsreader/news/collection/tests/favicon/builder/tests.py @@ -1,7 +1,7 @@ -from freezegun import freeze_time - from django.test import TestCase +from freezegun import freeze_time + from newsreader.news.collection.favicon import FaviconBuilder from newsreader.news.collection.tests.factories import CollectionRuleFactory from newsreader.news.collection.tests.favicon.builder.mocks import * diff --git a/src/newsreader/news/collection/tests/favicon/client/mocks.py b/src/newsreader/news/collection/tests/favicon/client/mocks.py index ba79b27..a4c5ee1 100644 --- a/src/newsreader/news/collection/tests/favicon/client/mocks.py +++ b/src/newsreader/news/collection/tests/favicon/client/mocks.py @@ -1,5 +1,6 @@ from bs4 import BeautifulSoup + simple_mock = BeautifulSoup( """ diff --git a/src/newsreader/news/collection/tests/favicon/collector/mocks.py b/src/newsreader/news/collection/tests/favicon/collector/mocks.py index 8e58167..097b1dd 100644 --- a/src/newsreader/news/collection/tests/favicon/collector/mocks.py +++ b/src/newsreader/news/collection/tests/favicon/collector/mocks.py @@ -2,6 +2,7 @@ from time import struct_time from bs4 import BeautifulSoup + feed_mock = { "bozo": 0, "encoding": "utf-8", diff --git a/src/newsreader/news/collection/tests/favicon/collector/tests.py b/src/newsreader/news/collection/tests/favicon/collector/tests.py index 6554de4..a292c16 100644 --- a/src/newsreader/news/collection/tests/favicon/collector/tests.py +++ b/src/newsreader/news/collection/tests/favicon/collector/tests.py @@ -1,14 +1,12 @@ from unittest.mock import MagicMock, patch +from django.test import TestCase +from django.utils import timezone + import pytz from bs4 import BeautifulSoup -from .mocks import feed_mock, website_mock - -from django.test import TestCase -from django.utils import timezone - from newsreader.news.collection.exceptions import ( StreamDeniedException, StreamException, @@ -20,6 +18,8 @@ from newsreader.news.collection.exceptions import ( from newsreader.news.collection.favicon import FaviconCollector from newsreader.news.collection.tests.factories import CollectionRuleFactory +from .mocks import feed_mock, website_mock + class FaviconCollectorTestCase(TestCase): def setUp(self): diff --git a/src/newsreader/news/collection/tests/feed/builder/mock_html.py b/src/newsreader/news/collection/tests/feed/builder/mock_html.py index 788495f..44d46f7 100644 --- a/src/newsreader/news/collection/tests/feed/builder/mock_html.py +++ b/src/newsreader/news/collection/tests/feed/builder/mock_html.py @@ -1,4 +1,4 @@ -html_summary = ''' +html_summary = """
@@ -7,4 +7,4 @@ html_summary = '''
-''' +""" diff --git a/src/newsreader/news/collection/tests/feed/builder/mocks.py b/src/newsreader/news/collection/tests/feed/builder/mocks.py index 631f582..a486626 100644 --- a/src/newsreader/news/collection/tests/feed/builder/mocks.py +++ b/src/newsreader/news/collection/tests/feed/builder/mocks.py @@ -2,877 +2,891 @@ from time import struct_time from .mock_html import html_summary + simple_mock = { - 'bozo': 0, - 'encoding': 'utf-8', - 'entries': [{ - 'author': 'A. Author', - 'guidislink': False, - 'href': '', - 'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '1152', - 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', - 'width': '2048' - }], - 'published': 'Mon, 20 May 2019 16:07:37 GMT', - 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), - 'summary': 'Foreign Minister Mohammad Javad Zarif says the US ' - 'president should try showing Iranians some respect.', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': 'Foreign Minister Mohammad Javad ' - 'Zarif says the US president should ' - 'try showing Iranians some ' - 'respect.' - }, - 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': "Trump's 'genocidal taunts' will not " - 'end Iran - Zarif' - } - }], - 'feed': { - 'image': { - 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', - 'link': 'https://www.bbc.co.uk/news/', - 'title': 'BBC News - Home', - 'language': 'en-gb', - 'link': 'https://www.bbc.co.uk/news/' + "bozo": 0, + "encoding": "utf-8", + "entries": [ + { + "author": "A. Author", + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "link": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "links": [ + { + "href": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "1152", + "url": "http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg", + "width": "2048", + } + ], + "published": "Mon, 20 May 2019 16:07:37 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + "summary": "Foreign Minister Mohammad Javad Zarif says the US " + "president should try showing Iranians some respect.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Foreign Minister Mohammad Javad " + "Zarif says the US president should " + "try showing Iranians some " + "respect.", }, - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'title': 'BBC News - Home', + "title": "Trump's 'genocidal taunts' will not end Iran - Zarif", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif", + }, + } + ], + "feed": { + "image": { + "href": "https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif", + "link": "https://www.bbc.co.uk/news/", + "title": "BBC News - Home", + "language": "en-gb", + "link": "https://www.bbc.co.uk/news/", + }, + "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "title": "BBC News - Home", }, - 'href': 'http://feeds.bbci.co.uk/news/rss.xml', - 'status': 200, - 'version': 'rss20' + "href": "http://feeds.bbci.co.uk/news/rss.xml", + "status": 200, + "version": "rss20", } multiple_mock = { - 'bozo': 0, - 'encoding': 'utf-8', - 'entries': [ + "bozo": 0, + "encoding": "utf-8", + "entries": [ { - 'author': 'A. Author', - 'guidislink': False, - 'href': '', - 'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '1152', - 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', - 'width': '2048' - }], - 'published': 'Mon, 20 May 2019 16:07:37 GMT', - 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), - 'summary': 'Foreign Minister Mohammad Javad Zarif says the US ' - 'president should try showing Iranians some respect.', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': 'Foreign Minister Mohammad Javad ' - 'Zarif says the US president should ' - 'try showing Iranians some ' - 'respect.' + "author": "A. Author", + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "link": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "links": [ + { + "href": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "1152", + "url": "http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg", + "width": "2048", + } + ], + "published": "Mon, 20 May 2019 16:07:37 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + "summary": "Foreign Minister Mohammad Javad Zarif says the US " + "president should try showing Iranians some respect.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Foreign Minister Mohammad Javad " + "Zarif says the US president should " + "try showing Iranians some " + "respect.", + }, + "title": "Trump's 'genocidal taunts' will not end Iran - Zarif", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif", }, - 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': "Trump's 'genocidal taunts' will not " - 'end Iran - Zarif' - } }, { - 'author': 'A. Author', - 'guidislink': False, - 'href': '', - 'id': 'https://www.bbc.co.uk/news/technology-48334739', - 'link': 'https://www.bbc.co.uk/news/technology-48334739', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/technology-48334739', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '432', - 'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg', - 'width': '768' - }], - 'published': 'Mon, 20 May 2019 12:19:19 GMT', - 'published_parsed': struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0,)), - 'summary': "Google's move to end business ties with Huawei will " - 'affect current devices and future purchases.', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': "Google's move to end business ties " - 'with Huawei will affect current ' - 'devices and future purchases.' + "author": "A. Author", + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/technology-48334739", + "link": "https://www.bbc.co.uk/news/technology-48334739", + "links": [ + { + "href": "https://www.bbc.co.uk/news/technology-48334739", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "432", + "url": "http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg", + "width": "768", + } + ], + "published": "Mon, 20 May 2019 12:19:19 GMT", + "published_parsed": struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0)), + "summary": "Google's move to end business ties with Huawei will " + "affect current devices and future purchases.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Google's move to end business ties " + "with Huawei will affect current " + "devices and future purchases.", + }, + "title": "Huawei's Android loss: How it affects you", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Huawei's Android loss: How it " "affects you", }, - 'title': "Huawei's Android loss: How it affects you", - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': "Huawei's Android loss: How it " - 'affects you' - } }, { - 'author': 'A. Author', - 'guidislink': False, - 'href': '', - 'id': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', - 'link': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '549', - 'url': 'http://c.files.bbci.co.uk/11D67/production/_107036037_lgbtheadjpg.jpg', - 'width': '976' - }], - 'published': 'Mon, 20 May 2019 16:32:38 GMT', - 'published_parsed': struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), - 'summary': 'Police are investigating the messages while an MP ' + "author": "A. Author", + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + "link": "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + "links": [ + { + "href": "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "549", + "url": "http://c.files.bbci.co.uk/11D67/production/_107036037_lgbtheadjpg.jpg", + "width": "976", + } + ], + "published": "Mon, 20 May 2019 16:32:38 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), + "summary": "Police are investigating the messages while an MP " 'calls for a protest exclusion zone "to protect ' 'children".', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': 'Police are investigating the ' - 'messages while an MP calls for a ' + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Police are investigating the " + "messages while an MP calls for a " 'protest exclusion zone "to protect ' - 'children".' + 'children".', + }, + "title": "Birmingham head teacher threatened over LGBT lessons", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Birmingham head teacher threatened " "over LGBT lessons", }, - 'title': 'Birmingham head teacher threatened over LGBT lessons', - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': 'Birmingham head teacher threatened ' - 'over LGBT lessons' - } }, ], - 'feed': { - 'image': { - 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', - 'link': 'https://www.bbc.co.uk/news/', - 'title': 'BBC News - Home', - 'language': 'en-gb', - 'link': 'https://www.bbc.co.uk/news/' - }, - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'title': 'BBC News - Home', + "feed": { + "image": { + "href": "https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif", + "link": "https://www.bbc.co.uk/news/", + "title": "BBC News - Home", + "language": "en-gb", + "link": "https://www.bbc.co.uk/news/", + }, + "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "title": "BBC News - Home", }, - 'href': 'http://feeds.bbci.co.uk/news/rss.xml', - 'status': 200, - 'version': 'rss20' + "href": "http://feeds.bbci.co.uk/news/rss.xml", + "status": 200, + "version": "rss20", } mock_without_identifier = { - 'bozo': 0, - 'encoding': 'utf-8', - 'entries': [ + "bozo": 0, + "encoding": "utf-8", + "entries": [ { - 'author': 'A. Author', - 'guidislink': False, - 'href': '', - 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '1152', - 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', - 'width': '2048' - }], - 'published': 'Mon, 20 May 2019 16:07:37 GMT', - 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), - 'summary': 'Foreign Minister Mohammad Javad Zarif says the US ' - 'president should try showing Iranians some respect.', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': 'Foreign Minister Mohammad Javad ' - 'Zarif says the US president should ' - 'try showing Iranians some ' - 'respect.' + "author": "A. Author", + "guidislink": False, + "href": "", + "link": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "links": [ + { + "href": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "1152", + "url": "http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg", + "width": "2048", + } + ], + "published": "Mon, 20 May 2019 16:07:37 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + "summary": "Foreign Minister Mohammad Javad Zarif says the US " + "president should try showing Iranians some respect.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Foreign Minister Mohammad Javad " + "Zarif says the US president should " + "try showing Iranians some " + "respect.", + }, + "title": "Trump's 'genocidal taunts' will not end Iran - Zarif", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif", }, - 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': "Trump's 'genocidal taunts' will not " - 'end Iran - Zarif' - } }, { - 'author': 'A. Author', - 'guidislink': False, - 'href': '', - 'id': None, - 'link': 'https://www.bbc.co.uk/news/technology-48334739', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/technology-48334739', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '432', - 'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg', - 'width': '768' - }], - 'published': 'Mon, 20 May 2019 12:19:19 GMT', - 'published_parsed': struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0,)), - 'summary': "Google's move to end business ties with Huawei will " - 'affect current devices and future purchases.', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': "Google's move to end business ties " - 'with Huawei will affect current ' - 'devices and future purchases.' + "author": "A. Author", + "guidislink": False, + "href": "", + "id": None, + "link": "https://www.bbc.co.uk/news/technology-48334739", + "links": [ + { + "href": "https://www.bbc.co.uk/news/technology-48334739", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "432", + "url": "http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg", + "width": "768", + } + ], + "published": "Mon, 20 May 2019 12:19:19 GMT", + "published_parsed": struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0)), + "summary": "Google's move to end business ties with Huawei will " + "affect current devices and future purchases.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Google's move to end business ties " + "with Huawei will affect current " + "devices and future purchases.", + }, + "title": "Huawei's Android loss: How it affects you", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Huawei's Android loss: How it " "affects you", }, - 'title': "Huawei's Android loss: How it affects you", - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': "Huawei's Android loss: How it " - 'affects you' - } }, ], - 'feed': { - 'image': { - 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', - 'link': 'https://www.bbc.co.uk/news/', - 'title': 'BBC News - Home', - 'language': 'en-gb', - 'link': 'https://www.bbc.co.uk/news/' - }, - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'title': 'BBC News - Home', + "feed": { + "image": { + "href": "https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif", + "link": "https://www.bbc.co.uk/news/", + "title": "BBC News - Home", + "language": "en-gb", + "link": "https://www.bbc.co.uk/news/", + }, + "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "title": "BBC News - Home", }, - 'href': 'http://feeds.bbci.co.uk/news/rss.xml', - 'status': 200, - 'version': 'rss20' + "href": "http://feeds.bbci.co.uk/news/rss.xml", + "status": 200, + "version": "rss20", } mock_without_publish_date = { - 'bozo': 0, - 'encoding': 'utf-8', - 'entries': [ + "bozo": 0, + "encoding": "utf-8", + "entries": [ { - 'author': 'A. Author', - 'guidislink': False, - 'href': '', - 'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '1152', - 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', - 'width': '2048' - }], - 'published': None, - 'published_parsed': None, - 'summary': 'Foreign Minister Mohammad Javad Zarif says the US ' - 'president should try showing Iranians some respect.', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': 'Foreign Minister Mohammad Javad ' - 'Zarif says the US president should ' - 'try showing Iranians some ' - 'respect.' + "author": "A. Author", + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "link": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "links": [ + { + "href": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "1152", + "url": "http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg", + "width": "2048", + } + ], + "published": None, + "published_parsed": None, + "summary": "Foreign Minister Mohammad Javad Zarif says the US " + "president should try showing Iranians some respect.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Foreign Minister Mohammad Javad " + "Zarif says the US president should " + "try showing Iranians some " + "respect.", + }, + "title": "Trump's 'genocidal taunts' will not end Iran - Zarif", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif", }, - 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': "Trump's 'genocidal taunts' will not " - 'end Iran - Zarif' - } }, { - 'author': 'A. Author', - 'guidislink': False, - 'href': '', - 'id': 'https://www.bbc.co.uk/news/technology-48334739', - 'link': 'https://www.bbc.co.uk/news/technology-48334739', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/technology-48334739', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '432', - 'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg', - 'width': '768' - }], - 'summary': "Google's move to end business ties with Huawei will " - 'affect current devices and future purchases.', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': "Google's move to end business ties " - 'with Huawei will affect current ' - 'devices and future purchases.' + "author": "A. Author", + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/technology-48334739", + "link": "https://www.bbc.co.uk/news/technology-48334739", + "links": [ + { + "href": "https://www.bbc.co.uk/news/technology-48334739", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "432", + "url": "http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg", + "width": "768", + } + ], + "summary": "Google's move to end business ties with Huawei will " + "affect current devices and future purchases.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Google's move to end business ties " + "with Huawei will affect current " + "devices and future purchases.", + }, + "title": "Huawei's Android loss: How it affects you", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Huawei's Android loss: How it " "affects you", }, - 'title': "Huawei's Android loss: How it affects you", - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': "Huawei's Android loss: How it " - 'affects you' - } }, ], - 'feed': { - 'image': { - 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', - 'link': 'https://www.bbc.co.uk/news/', - 'title': 'BBC News - Home', - 'language': 'en-gb', - 'link': 'https://www.bbc.co.uk/news/' - }, - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'title': 'BBC News - Home', + "feed": { + "image": { + "href": "https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif", + "link": "https://www.bbc.co.uk/news/", + "title": "BBC News - Home", + "language": "en-gb", + "link": "https://www.bbc.co.uk/news/", + }, + "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "title": "BBC News - Home", }, - 'href': 'http://feeds.bbci.co.uk/news/rss.xml', - 'status': 200, - 'version': 'rss20' + "href": "http://feeds.bbci.co.uk/news/rss.xml", + "status": 200, + "version": "rss20", } mock_without_url = { - 'bozo': 0, - 'encoding': 'utf-8', - 'entries': [ + "bozo": 0, + "encoding": "utf-8", + "entries": [ { - 'author': 'A. Author', - 'guidislink': False, - 'href': '', - 'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'media_thumbnail': [{ - 'height': '1152', - 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', - 'width': '2048' - }], - 'published': 'Mon, 20 May 2019 16:07:37 GMT', - 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), - 'published': None, - 'published_parsed': None, - 'summary': 'Foreign Minister Mohammad Javad Zarif says the US ' - 'president should try showing Iranians some respect.', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': 'Foreign Minister Mohammad Javad ' - 'Zarif says the US president should ' - 'try showing Iranians some ' - 'respect.' + "author": "A. Author", + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "media_thumbnail": [ + { + "height": "1152", + "url": "http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg", + "width": "2048", + } + ], + "published": "Mon, 20 May 2019 16:07:37 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + "published": None, + "published_parsed": None, + "summary": "Foreign Minister Mohammad Javad Zarif says the US " + "president should try showing Iranians some respect.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Foreign Minister Mohammad Javad " + "Zarif says the US president should " + "try showing Iranians some " + "respect.", + }, + "title": "Trump's 'genocidal taunts' will not end Iran - Zarif", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif", }, - 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': "Trump's 'genocidal taunts' will not " - 'end Iran - Zarif' - } }, { - 'author': 'A. Author', - 'guidislink': False, - 'href': '', - 'id': 'https://www.bbc.co.uk/news/technology-48334739', - 'link': None, - 'links': [], - 'media_thumbnail': [{ - 'height': '432', - 'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg', - 'width': '768' - }], - 'published': 'Mon, 20 May 2019 16:07:37 GMT', - 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), - - 'summary': "Google's move to end business ties with Huawei will " - 'affect current devices and future purchases.', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': "Google's move to end business ties " - 'with Huawei will affect current ' - 'devices and future purchases.' + "author": "A. Author", + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/technology-48334739", + "link": None, + "links": [], + "media_thumbnail": [ + { + "height": "432", + "url": "http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg", + "width": "768", + } + ], + "published": "Mon, 20 May 2019 16:07:37 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + "summary": "Google's move to end business ties with Huawei will " + "affect current devices and future purchases.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Google's move to end business ties " + "with Huawei will affect current " + "devices and future purchases.", + }, + "title": "Huawei's Android loss: How it affects you", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Huawei's Android loss: How it " "affects you", }, - 'title': "Huawei's Android loss: How it affects you", - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': "Huawei's Android loss: How it " - 'affects you' - } }, ], - 'feed': { - 'image': { - 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', - 'link': 'https://www.bbc.co.uk/news/', - 'title': 'BBC News - Home', - 'language': 'en-gb', - 'link': 'https://www.bbc.co.uk/news/' - }, - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'title': 'BBC News - Home', + "feed": { + "image": { + "href": "https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif", + "link": "https://www.bbc.co.uk/news/", + "title": "BBC News - Home", + "language": "en-gb", + "link": "https://www.bbc.co.uk/news/", + }, + "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "title": "BBC News - Home", }, - 'href': 'http://feeds.bbci.co.uk/news/rss.xml', - 'status': 200, - 'version': 'rss20' + "href": "http://feeds.bbci.co.uk/news/rss.xml", + "status": 200, + "version": "rss20", } mock_without_body = { - 'bozo': 0, - 'encoding': 'utf-8', - 'entries': [ + "bozo": 0, + "encoding": "utf-8", + "entries": [ { - 'author': 'A. Author', - 'guidislink': False, - 'href': '', - 'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '1152', - 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', - 'width': '2048' - }], - 'published': 'Mon, 20 May 2019 16:07:37 GMT', - 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), - 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': "Trump's 'genocidal taunts' will not " - 'end Iran - Zarif' - } + "author": "A. Author", + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "link": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "links": [ + { + "href": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "1152", + "url": "http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg", + "width": "2048", + } + ], + "published": "Mon, 20 May 2019 16:07:37 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + "title": "Trump's 'genocidal taunts' will not end Iran - Zarif", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif", + }, }, { - 'author': 'A. Author', - 'guidislink': False, - 'href': '', - 'id': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', - 'link': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '549', - 'url': 'http://c.files.bbci.co.uk/11D67/production/_107036037_lgbtheadjpg.jpg', - 'width': '976' - }], - 'published': 'Mon, 20 May 2019 16:32:38 GMT', - 'published_parsed': struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), - 'summary': None, - 'summary_detail': {}, - 'title': 'Birmingham head teacher threatened over LGBT lessons', - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': 'Birmingham head teacher threatened ' - 'over LGBT lessons' + "author": "A. Author", + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + "link": "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + "links": [ + { + "href": "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + "rel": "alternate", + "type": "text/html", } + ], + "media_thumbnail": [ + { + "height": "549", + "url": "http://c.files.bbci.co.uk/11D67/production/_107036037_lgbtheadjpg.jpg", + "width": "976", + } + ], + "published": "Mon, 20 May 2019 16:32:38 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), + "summary": None, + "summary_detail": {}, + "title": "Birmingham head teacher threatened over LGBT lessons", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Birmingham head teacher threatened " "over LGBT lessons", + }, }, ], - 'feed': { - 'image': { - 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', - 'link': 'https://www.bbc.co.uk/news/', - 'title': 'BBC News - Home', - 'language': 'en-gb', - 'link': 'https://www.bbc.co.uk/news/' - }, - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'title': 'BBC News - Home', + "feed": { + "image": { + "href": "https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif", + "link": "https://www.bbc.co.uk/news/", + "title": "BBC News - Home", + "language": "en-gb", + "link": "https://www.bbc.co.uk/news/", + }, + "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "title": "BBC News - Home", }, - 'href': 'http://feeds.bbci.co.uk/news/rss.xml', - 'status': 200, - 'version': 'rss20' + "href": "http://feeds.bbci.co.uk/news/rss.xml", + "status": 200, + "version": "rss20", } mock_without_author = { - 'bozo': 0, - 'encoding': 'utf-8', - 'entries': [ + "bozo": 0, + "encoding": "utf-8", + "entries": [ { - 'guidislink': False, - 'href': '', - 'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '1152', - 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', - 'width': '2048' - }], - 'published': 'Mon, 20 May 2019 16:07:37 GMT', - 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), - 'summary': 'Foreign Minister Mohammad Javad Zarif says the US ' - 'president should try showing Iranians some respect.', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': 'Foreign Minister Mohammad Javad ' - 'Zarif says the US president should ' - 'try showing Iranians some ' - 'respect.' + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "link": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "links": [ + { + "href": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "1152", + "url": "http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg", + "width": "2048", + } + ], + "published": "Mon, 20 May 2019 16:07:37 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + "summary": "Foreign Minister Mohammad Javad Zarif says the US " + "president should try showing Iranians some respect.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Foreign Minister Mohammad Javad " + "Zarif says the US president should " + "try showing Iranians some " + "respect.", + }, + "title": "Trump's 'genocidal taunts' will not end Iran - Zarif", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif", }, - 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': "Trump's 'genocidal taunts' will not " - 'end Iran - Zarif' - } }, { - 'author': None, - 'guidislink': False, - 'href': '', - 'id': 'https://www.bbc.co.uk/news/technology-48334739', - 'link': 'https://www.bbc.co.uk/news/technology-48334739', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/technology-48334739', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '432', - 'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg', - 'width': '768' - }], - 'published': 'Mon, 20 May 2019 12:19:19 GMT', - 'published_parsed': struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0,)), - 'summary': "Google's move to end business ties with Huawei will " - 'affect current devices and future purchases.', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': "Google's move to end business ties " - 'with Huawei will affect current ' - 'devices and future purchases.' + "author": None, + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/technology-48334739", + "link": "https://www.bbc.co.uk/news/technology-48334739", + "links": [ + { + "href": "https://www.bbc.co.uk/news/technology-48334739", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "432", + "url": "http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg", + "width": "768", + } + ], + "published": "Mon, 20 May 2019 12:19:19 GMT", + "published_parsed": struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0)), + "summary": "Google's move to end business ties with Huawei will " + "affect current devices and future purchases.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Google's move to end business ties " + "with Huawei will affect current " + "devices and future purchases.", + }, + "title": "Huawei's Android loss: How it affects you", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Huawei's Android loss: How it " "affects you", }, - 'title': "Huawei's Android loss: How it affects you", - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': "Huawei's Android loss: How it " - 'affects you' - } }, ], - 'feed': { - 'image': { - 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', - 'link': 'https://www.bbc.co.uk/news/', - 'title': 'BBC News - Home', - 'language': 'en-gb', - 'link': 'https://www.bbc.co.uk/news/' - }, - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'title': 'BBC News - Home', + "feed": { + "image": { + "href": "https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif", + "link": "https://www.bbc.co.uk/news/", + "title": "BBC News - Home", + "language": "en-gb", + "link": "https://www.bbc.co.uk/news/", + }, + "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "title": "BBC News - Home", }, - 'href': 'http://feeds.bbci.co.uk/news/rss.xml', - 'status': 200, - 'version': 'rss20' + "href": "http://feeds.bbci.co.uk/news/rss.xml", + "status": 200, + "version": "rss20", } -mock_without_entries = { - 'entries': [], -} +mock_without_entries = {"entries": []} mock_with_update_entries = { - 'bozo': 0, - 'encoding': 'utf-8', - 'entries': [ + "bozo": 0, + "encoding": "utf-8", + "entries": [ { - 'author': 'A. Author', - 'guidislink': False, - 'href': '', - 'id': '28f79ae4-8f9a-11e9-b143-00163ef6bee7', - 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '1152', - 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', - 'width': '2048' - }], - 'published': 'Mon, 20 May 2019 16:07:37 GMT', - 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), - 'summary': 'Foreign Minister Mohammad Javad Zarif says the US ' - 'president should try showing Iranians some respect.', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': 'Foreign Minister Mohammad Javad ' - 'Zarif says the US president should ' - 'try showing Iranians some ' - 'respect.' + "author": "A. Author", + "guidislink": False, + "href": "", + "id": "28f79ae4-8f9a-11e9-b143-00163ef6bee7", + "link": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "links": [ + { + "href": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "1152", + "url": "http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg", + "width": "2048", + } + ], + "published": "Mon, 20 May 2019 16:07:37 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + "summary": "Foreign Minister Mohammad Javad Zarif says the US " + "president should try showing Iranians some respect.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Foreign Minister Mohammad Javad " + "Zarif says the US president should " + "try showing Iranians some " + "respect.", + }, + "title": "Trump's 'genocidal taunts' will not end Iran - Zarif", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif", }, - 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': "Trump's 'genocidal taunts' will not " - 'end Iran - Zarif' - } }, { - 'author': 'A. Author', - 'guidislink': False, - 'href': '', - 'id': 'a5479c66-8fae-11e9-8422-00163ef6bee7', - 'link': 'https://www.bbc.co.uk/news/technology-48334739', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/technology-48334739', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '432', - 'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg', - 'width': '768' - }], - 'published': 'Mon, 20 May 2019 12:19:19 GMT', - 'published_parsed': struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0,)), - 'summary': "Google's move to end business ties with Huawei will " - 'affect current devices and future purchases.', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': "Google's move to end business ties " - 'with Huawei will affect current ' - 'devices and future purchases.' + "author": "A. Author", + "guidislink": False, + "href": "", + "id": "a5479c66-8fae-11e9-8422-00163ef6bee7", + "link": "https://www.bbc.co.uk/news/technology-48334739", + "links": [ + { + "href": "https://www.bbc.co.uk/news/technology-48334739", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "432", + "url": "http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg", + "width": "768", + } + ], + "published": "Mon, 20 May 2019 12:19:19 GMT", + "published_parsed": struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0)), + "summary": "Google's move to end business ties with Huawei will " + "affect current devices and future purchases.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Google's move to end business ties " + "with Huawei will affect current " + "devices and future purchases.", + }, + "title": "Huawei's Android loss: How it affects you", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Huawei's Android loss: How it " "affects you", }, - 'title': "Huawei's Android loss: How it affects you", - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': "Huawei's Android loss: How it " - 'affects you' - } }, { - 'author': 'A. Author', - 'guidislink': False, - 'href': '', - 'id': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', - 'link': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '549', - 'url': 'http://c.files.bbci.co.uk/11D67/production/_107036037_lgbtheadjpg.jpg', - 'width': '976' - }], - 'published': 'Mon, 20 May 2019 16:32:38 GMT', - 'published_parsed': struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), - 'summary': 'Police are investigating the messages while an MP ' + "author": "A. Author", + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + "link": "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + "links": [ + { + "href": "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "549", + "url": "http://c.files.bbci.co.uk/11D67/production/_107036037_lgbtheadjpg.jpg", + "width": "976", + } + ], + "published": "Mon, 20 May 2019 16:32:38 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), + "summary": "Police are investigating the messages while an MP " 'calls for a protest exclusion zone "to protect ' 'children".', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': 'Police are investigating the ' - 'messages while an MP calls for a ' + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Police are investigating the " + "messages while an MP calls for a " 'protest exclusion zone "to protect ' - 'children".' + 'children".', + }, + "title": "Birmingham head teacher threatened over LGBT lessons", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Birmingham head teacher threatened " "over LGBT lessons", }, - 'title': 'Birmingham head teacher threatened over LGBT lessons', - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': 'Birmingham head teacher threatened ' - 'over LGBT lessons' - } }, ], - 'feed': { - 'image': { - 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', - 'link': 'https://www.bbc.co.uk/news/', - 'title': 'BBC News - Home', - 'language': 'en-gb', - 'link': 'https://www.bbc.co.uk/news/' - }, - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'title': 'BBC News - Home', + "feed": { + "image": { + "href": "https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif", + "link": "https://www.bbc.co.uk/news/", + "title": "BBC News - Home", + "language": "en-gb", + "link": "https://www.bbc.co.uk/news/", + }, + "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "title": "BBC News - Home", }, - 'href': 'http://feeds.bbci.co.uk/news/rss.xml', - 'status': 200, - 'version': 'rss20' + "href": "http://feeds.bbci.co.uk/news/rss.xml", + "status": 200, + "version": "rss20", } mock_with_html = { - 'bozo': 0, - 'encoding': 'utf-8', - 'entries': [ + "bozo": 0, + "encoding": "utf-8", + "entries": [ { - 'author': 'A. Author', - 'guidislink': False, - 'href': '', - 'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '1152', - 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', - 'width': '2048' - }], - 'published': 'Mon, 20 May 2019 16:07:37 GMT', - 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), - 'summary': html_summary, - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': 'Foreign Minister Mohammad Javad ' - 'Zarif says the US president should ' - 'try showing Iranians some ' - 'respect.' + "author": "A. Author", + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "link": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "links": [ + { + "href": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "1152", + "url": "http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg", + "width": "2048", + } + ], + "published": "Mon, 20 May 2019 16:07:37 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + "summary": html_summary, + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Foreign Minister Mohammad Javad " + "Zarif says the US president should " + "try showing Iranians some " + "respect.", }, - 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': "Trump's 'genocidal taunts' will not " - 'end Iran - Zarif' - } - }, + "title": "Trump's 'genocidal taunts' will not end Iran - Zarif", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif", + }, + } ], - 'feed': { - 'image': { - 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', - 'link': 'https://www.bbc.co.uk/news/', - 'title': 'BBC News - Home', - 'language': 'en-gb', - 'link': 'https://www.bbc.co.uk/news/' - }, - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'title': 'BBC News - Home', + "feed": { + "image": { + "href": "https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif", + "link": "https://www.bbc.co.uk/news/", + "title": "BBC News - Home", + "language": "en-gb", + "link": "https://www.bbc.co.uk/news/", + }, + "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "title": "BBC News - Home", }, - 'href': 'http://feeds.bbci.co.uk/news/rss.xml', - 'status': 200, - 'version': 'rss20' + "href": "http://feeds.bbci.co.uk/news/rss.xml", + "status": 200, + "version": "rss20", } diff --git a/src/newsreader/news/collection/tests/feed/builder/tests.py b/src/newsreader/news/collection/tests/feed/builder/tests.py index 49a130a..6efd432 100644 --- a/src/newsreader/news/collection/tests/feed/builder/tests.py +++ b/src/newsreader/news/collection/tests/feed/builder/tests.py @@ -1,20 +1,20 @@ from datetime import date, datetime, time from unittest.mock import MagicMock +from django.test import TestCase +from django.utils import timezone + import pytz from freezegun import freeze_time -from .mocks import * - -from django.test import TestCase -from django.utils import timezone - from newsreader.news.collection.feed import FeedBuilder from newsreader.news.collection.tests.factories import CollectionRuleFactory from newsreader.news.posts.models import Post from newsreader.news.posts.tests.factories import PostFactory +from .mocks import * + class FeedBuilderTestCase(TestCase): def setUp(self): diff --git a/src/newsreader/news/collection/tests/feed/client/mocks.py b/src/newsreader/news/collection/tests/feed/client/mocks.py index 5853eb7..e055e7b 100644 --- a/src/newsreader/news/collection/tests/feed/client/mocks.py +++ b/src/newsreader/news/collection/tests/feed/client/mocks.py @@ -1,61 +1,63 @@ from time import struct_time + simple_mock = { - 'bozo': 0, - 'encoding': 'utf-8', - 'entries': [{ - 'guidislink': False, - 'href': '', - 'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '1152', - 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', - 'width': '2048' - }], - 'published': 'Mon, 20 May 2019 16:07:37 GMT', - 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), - 'summary': 'Foreign Minister Mohammad Javad Zarif says the US ' - 'president should try showing Iranians some respect.', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': 'Foreign Minister Mohammad Javad ' - 'Zarif says the US president should ' - 'try showing Iranians some ' - 'respect.' - }, - 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': "Trump's 'genocidal taunts' will not " - 'end Iran - Zarif' - } - }], - 'feed': { - 'image': { - 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', - 'link': 'https://www.bbc.co.uk/news/', - 'title': 'BBC News - Home', - 'language': 'en-gb', - 'link': 'https://www.bbc.co.uk/news/' + "bozo": 0, + "encoding": "utf-8", + "entries": [ + { + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "link": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "links": [ + { + "href": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "1152", + "url": "http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg", + "width": "2048", + } + ], + "published": "Mon, 20 May 2019 16:07:37 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + "summary": "Foreign Minister Mohammad Javad Zarif says the US " + "president should try showing Iranians some respect.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Foreign Minister Mohammad Javad " + "Zarif says the US president should " + "try showing Iranians some " + "respect.", }, - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'title': 'BBC News - Home', + "title": "Trump's 'genocidal taunts' will not end Iran - Zarif", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif", + }, + } + ], + "feed": { + "image": { + "href": "https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif", + "link": "https://www.bbc.co.uk/news/", + "title": "BBC News - Home", + "language": "en-gb", + "link": "https://www.bbc.co.uk/news/", + }, + "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "title": "BBC News - Home", }, - 'href': 'http://feeds.bbci.co.uk/news/rss.xml', - 'status': 200, - 'version': 'rss20' + "href": "http://feeds.bbci.co.uk/news/rss.xml", + "status": 200, + "version": "rss20", } diff --git a/src/newsreader/news/collection/tests/feed/client/tests.py b/src/newsreader/news/collection/tests/feed/client/tests.py index e49762d..bd9e4eb 100644 --- a/src/newsreader/news/collection/tests/feed/client/tests.py +++ b/src/newsreader/news/collection/tests/feed/client/tests.py @@ -1,7 +1,5 @@ from unittest.mock import MagicMock, patch -from .mocks import simple_mock - from django.test import TestCase from django.utils import timezone @@ -15,6 +13,8 @@ from newsreader.news.collection.exceptions import ( from newsreader.news.collection.feed import FeedClient from newsreader.news.collection.tests.factories import CollectionRuleFactory +from .mocks import simple_mock + class FeedClientTestCase(TestCase): def setUp(self): diff --git a/src/newsreader/news/collection/tests/feed/collector/mocks.py b/src/newsreader/news/collection/tests/feed/collector/mocks.py index 930e977..211f4ef 100644 --- a/src/newsreader/news/collection/tests/feed/collector/mocks.py +++ b/src/newsreader/news/collection/tests/feed/collector/mocks.py @@ -1,430 +1,442 @@ from time import struct_time + multiple_mock = { - 'bozo': 0, - 'encoding': 'utf-8', - 'entries': [ + "bozo": 0, + "encoding": "utf-8", + "entries": [ { - 'guidislink': False, - 'href': '', - 'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '1152', - 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', - 'width': '2048' - }], - 'published': 'Mon, 20 May 2019 16:07:37 GMT', - 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), - 'summary': 'Foreign Minister Mohammad Javad Zarif says the US ' - 'president should try showing Iranians some respect.', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': 'Foreign Minister Mohammad Javad ' - 'Zarif says the US president should ' - 'try showing Iranians some ' - 'respect.' + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "link": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "links": [ + { + "href": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "1152", + "url": "http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg", + "width": "2048", + } + ], + "published": "Mon, 20 May 2019 16:07:37 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + "summary": "Foreign Minister Mohammad Javad Zarif says the US " + "president should try showing Iranians some respect.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Foreign Minister Mohammad Javad " + "Zarif says the US president should " + "try showing Iranians some " + "respect.", + }, + "title": "Trump's 'genocidal taunts' will not end Iran - Zarif", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif", }, - 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': "Trump's 'genocidal taunts' will not " - 'end Iran - Zarif' - } }, { - 'guidislink': False, - 'href': '', - 'id': 'https://www.bbc.co.uk/news/technology-48334739', - 'link': 'https://www.bbc.co.uk/news/technology-48334739', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/technology-48334739', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '432', - 'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg', - 'width': '768' - }], - 'published': 'Mon, 20 May 2019 12:19:19 GMT', - 'published_parsed': struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0,)), - 'summary': "Google's move to end business ties with Huawei will " - 'affect current devices and future purchases.', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': "Google's move to end business ties " - 'with Huawei will affect current ' - 'devices and future purchases.' + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/technology-48334739", + "link": "https://www.bbc.co.uk/news/technology-48334739", + "links": [ + { + "href": "https://www.bbc.co.uk/news/technology-48334739", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "432", + "url": "http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg", + "width": "768", + } + ], + "published": "Mon, 20 May 2019 12:19:19 GMT", + "published_parsed": struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0)), + "summary": "Google's move to end business ties with Huawei will " + "affect current devices and future purchases.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Google's move to end business ties " + "with Huawei will affect current " + "devices and future purchases.", + }, + "title": "Huawei's Android loss: How it affects you", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Huawei's Android loss: How it " "affects you", }, - 'title': "Huawei's Android loss: How it affects you", - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': "Huawei's Android loss: How it " - 'affects you' - } }, { - 'guidislink': False, - 'href': '', - 'id': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', - 'link': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '549', - 'url': 'http://c.files.bbci.co.uk/11D67/production/_107036037_lgbtheadjpg.jpg', - 'width': '976' - }], - 'published': 'Mon, 20 May 2019 16:32:38 GMT', - 'published_parsed': struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), - 'summary': 'Police are investigating the messages while an MP ' + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + "link": "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + "links": [ + { + "href": "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "549", + "url": "http://c.files.bbci.co.uk/11D67/production/_107036037_lgbtheadjpg.jpg", + "width": "976", + } + ], + "published": "Mon, 20 May 2019 16:32:38 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), + "summary": "Police are investigating the messages while an MP " 'calls for a protest exclusion zone "to protect ' 'children".', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': 'Police are investigating the ' - 'messages while an MP calls for a ' + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Police are investigating the " + "messages while an MP calls for a " 'protest exclusion zone "to protect ' - 'children".' + 'children".', + }, + "title": "Birmingham head teacher threatened over LGBT lessons", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Birmingham head teacher threatened " "over LGBT lessons", }, - 'title': 'Birmingham head teacher threatened over LGBT lessons', - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': 'Birmingham head teacher threatened ' - 'over LGBT lessons' - } }, ], - 'feed': { - 'image': { - 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', - 'link': 'https://www.bbc.co.uk/news/', - 'title': 'BBC News - Home', - 'language': 'en-gb', - 'link': 'https://www.bbc.co.uk/news/' - }, - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'title': 'BBC News - Home', + "feed": { + "image": { + "href": "https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif", + "link": "https://www.bbc.co.uk/news/", + "title": "BBC News - Home", + "language": "en-gb", + "link": "https://www.bbc.co.uk/news/", + }, + "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "title": "BBC News - Home", }, - 'href': 'http://feeds.bbci.co.uk/news/rss.xml', - 'status': 200, - 'version': 'rss20' + "href": "http://feeds.bbci.co.uk/news/rss.xml", + "status": 200, + "version": "rss20", } empty_mock = { - 'bozo': 0, - 'encoding': 'utf-8', - 'entries': [], - 'feed': { - 'image': { - 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', - 'link': 'https://www.bbc.co.uk/news/', - 'title': 'BBC News - Home', - 'language': 'en-gb', - 'link': 'https://www.bbc.co.uk/news/' - }, - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'title': 'BBC News - Home', + "bozo": 0, + "encoding": "utf-8", + "entries": [], + "feed": { + "image": { + "href": "https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif", + "link": "https://www.bbc.co.uk/news/", + "title": "BBC News - Home", + "language": "en-gb", + "link": "https://www.bbc.co.uk/news/", + }, + "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "title": "BBC News - Home", }, - 'href': 'http://feeds.bbci.co.uk/news/rss.xml', - 'status': 200, - 'version': 'rss20' + "href": "http://feeds.bbci.co.uk/news/rss.xml", + "status": 200, + "version": "rss20", } duplicate_mock = { - 'bozo': 0, - 'encoding': 'utf-8', - 'entries': [ + "bozo": 0, + "encoding": "utf-8", + "entries": [ { - 'guidislink': False, - 'href': '', - 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '1152', - 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', - 'width': '2048' - }], - 'published': 'Mon, 20 May 2019 16:07:37 GMT', - 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), - 'summary': 'Foreign Minister Mohammad Javad Zarif says the US ' - 'president should try showing Iranians some respect.', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': 'Foreign Minister Mohammad Javad ' - 'Zarif says the US president should ' - 'try showing Iranians some ' - 'respect.' + "guidislink": False, + "href": "", + "link": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "links": [ + { + "href": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "1152", + "url": "http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg", + "width": "2048", + } + ], + "published": "Mon, 20 May 2019 16:07:37 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + "summary": "Foreign Minister Mohammad Javad Zarif says the US " + "president should try showing Iranians some respect.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Foreign Minister Mohammad Javad " + "Zarif says the US president should " + "try showing Iranians some " + "respect.", + }, + "title": "Trump's 'genocidal taunts' will not end Iran - Zarif", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif", }, - 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': "Trump's 'genocidal taunts' will not " - 'end Iran - Zarif' - } }, { - 'guidislink': False, - 'href': '', - 'link': 'https://www.bbc.co.uk/news/technology-48334739', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/technology-48334739', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '432', - 'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg', - 'width': '768' - }], - 'published': 'Mon, 20 May 2019 12:19:19 GMT', - 'published_parsed': struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0,)), - 'summary': "Google's move to end business ties with Huawei will " - 'affect current devices and future purchases.', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': "Google's move to end business ties " - 'with Huawei will affect current ' - 'devices and future purchases.' + "guidislink": False, + "href": "", + "link": "https://www.bbc.co.uk/news/technology-48334739", + "links": [ + { + "href": "https://www.bbc.co.uk/news/technology-48334739", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "432", + "url": "http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg", + "width": "768", + } + ], + "published": "Mon, 20 May 2019 12:19:19 GMT", + "published_parsed": struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0)), + "summary": "Google's move to end business ties with Huawei will " + "affect current devices and future purchases.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Google's move to end business ties " + "with Huawei will affect current " + "devices and future purchases.", + }, + "title": "Huawei's Android loss: How it affects you", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Huawei's Android loss: How it " "affects you", }, - 'title': "Huawei's Android loss: How it affects you", - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': "Huawei's Android loss: How it " - 'affects you' - } }, { - 'guidislink': False, - 'href': '', - 'link': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '549', - 'url': 'http://c.files.bbci.co.uk/11D67/production/_107036037_lgbtheadjpg.jpg', - 'width': '976' - }], - 'published': 'Mon, 20 May 2019 16:32:38 GMT', - 'published_parsed': struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), - 'summary': 'Police are investigating the messages while an MP ' + "guidislink": False, + "href": "", + "link": "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + "links": [ + { + "href": "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "549", + "url": "http://c.files.bbci.co.uk/11D67/production/_107036037_lgbtheadjpg.jpg", + "width": "976", + } + ], + "published": "Mon, 20 May 2019 16:32:38 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), + "summary": "Police are investigating the messages while an MP " 'calls for a protest exclusion zone "to protect ' 'children".', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': 'Police are investigating the ' - 'messages while an MP calls for a ' + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Police are investigating the " + "messages while an MP calls for a " 'protest exclusion zone "to protect ' - 'children".' + 'children".', + }, + "title": "Birmingham head teacher threatened over LGBT lessons", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Birmingham head teacher threatened " "over LGBT lessons", }, - 'title': 'Birmingham head teacher threatened over LGBT lessons', - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': 'Birmingham head teacher threatened ' - 'over LGBT lessons' - } }, ], - 'feed': { - 'image': { - 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', - 'link': 'https://www.bbc.co.uk/news/', - 'title': 'BBC News - Home', - 'language': 'en-gb', - 'link': 'https://www.bbc.co.uk/news/' - }, - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'title': 'BBC News - Home', + "feed": { + "image": { + "href": "https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif", + "link": "https://www.bbc.co.uk/news/", + "title": "BBC News - Home", + "language": "en-gb", + "link": "https://www.bbc.co.uk/news/", + }, + "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "title": "BBC News - Home", }, - 'href': 'http://feeds.bbci.co.uk/news/rss.xml', - 'status': 200, - 'version': 'rss20' + "href": "http://feeds.bbci.co.uk/news/rss.xml", + "status": 200, + "version": "rss20", } multiple_update_mock = { - 'bozo': 0, - 'encoding': 'utf-8', - 'entries': [ + "bozo": 0, + "encoding": "utf-8", + "entries": [ { - 'guidislink': False, - 'href': '', - 'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '1152', - 'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg', - 'width': '2048' - }], - 'published': 'Mon, 20 May 2019 16:07:37 GMT', - 'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), - 'summary': 'Foreign Minister Mohammad Javad Zarif says the US ' - 'president should try showing Iranians some respect.', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': 'Foreign Minister Mohammad Javad ' - 'Zarif says the US president should ' - 'try showing Iranians some ' - 'respect.' + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "link": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "links": [ + { + "href": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "1152", + "url": "http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg", + "width": "2048", + } + ], + "published": "Mon, 20 May 2019 16:07:37 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + "summary": "Foreign Minister Mohammad Javad Zarif says the US " + "president should try showing Iranians some respect.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Foreign Minister Mohammad Javad " + "Zarif says the US president should " + "try showing Iranians some " + "respect.", + }, + "title": "Trump's 'genocidal taunts' will not end Iran - Zarif", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif", }, - 'title': "Trump's 'genocidal taunts' will not end Iran - Zarif", - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': "Trump's 'genocidal taunts' will not " - 'end Iran - Zarif' - } }, { - 'guidislink': False, - 'href': '', - 'id': 'https://www.bbc.co.uk/news/technology-48334739', - 'link': 'https://www.bbc.co.uk/news/technology-48334739', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/technology-48334739', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '432', - 'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg', - 'width': '768' - }], - 'published': 'Mon, 20 May 2019 12:19:19 GMT', - 'published_parsed': struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0,)), - 'summary': "Google's move to end business ties with Huawei will " - 'affect current devices and future purchases.', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': "Google's move to end business ties " - 'with Huawei will affect current ' - 'devices and future purchases.' + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/technology-48334739", + "link": "https://www.bbc.co.uk/news/technology-48334739", + "links": [ + { + "href": "https://www.bbc.co.uk/news/technology-48334739", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "432", + "url": "http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg", + "width": "768", + } + ], + "published": "Mon, 20 May 2019 12:19:19 GMT", + "published_parsed": struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0)), + "summary": "Google's move to end business ties with Huawei will " + "affect current devices and future purchases.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Google's move to end business ties " + "with Huawei will affect current " + "devices and future purchases.", + }, + "title": "Huawei's Android loss: How it affects you", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Huawei's Android loss: How it " "affects you", }, - 'title': "Huawei's Android loss: How it affects you", - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': "Huawei's Android loss: How it " - 'affects you' - } }, { - 'guidislink': False, - 'href': '', - 'id': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', - 'link': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'media_thumbnail': [{ - 'height': '549', - 'url': 'http://c.files.bbci.co.uk/11D67/production/_107036037_lgbtheadjpg.jpg', - 'width': '976' - }], - 'published': 'Mon, 20 May 2019 16:32:38 GMT', - 'published_parsed': struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), - 'summary': 'Police are investigating the messages while an MP ' + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + "link": "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + "links": [ + { + "href": "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "549", + "url": "http://c.files.bbci.co.uk/11D67/production/_107036037_lgbtheadjpg.jpg", + "width": "976", + } + ], + "published": "Mon, 20 May 2019 16:32:38 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), + "summary": "Police are investigating the messages while an MP " 'calls for a protest exclusion zone "to protect ' 'children".', - 'summary_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/html', - 'value': 'Police are investigating the ' - 'messages while an MP calls for a ' + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Police are investigating the " + "messages while an MP calls for a " 'protest exclusion zone "to protect ' - 'children".' + 'children".', + }, + "title": "Birmingham head teacher threatened over LGBT lessons", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Birmingham head teacher threatened " "over LGBT lessons", }, - 'title': 'Birmingham head teacher threatened over LGBT lessons', - 'title_detail': { - 'base': 'http://feeds.bbci.co.uk/news/rss.xml', - 'language': None, - 'type': 'text/plain', - 'value': 'Birmingham head teacher threatened ' - 'over LGBT lessons' - } }, ], - 'feed': { - 'image': { - 'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif', - 'link': 'https://www.bbc.co.uk/news/', - 'title': 'BBC News - Home', - 'language': 'en-gb', - 'link': 'https://www.bbc.co.uk/news/' - }, - 'links': [{ - 'href': 'https://www.bbc.co.uk/news/', - 'rel': 'alternate', - 'type': 'text/html' - }], - 'title': 'BBC News - Home', + "feed": { + "image": { + "href": "https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif", + "link": "https://www.bbc.co.uk/news/", + "title": "BBC News - Home", + "language": "en-gb", + "link": "https://www.bbc.co.uk/news/", + }, + "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "title": "BBC News - Home", }, - 'href': 'http://feeds.bbci.co.uk/news/rss.xml', - 'status': 200, - 'version': 'rss20' + "href": "http://feeds.bbci.co.uk/news/rss.xml", + "status": 200, + "version": "rss20", } diff --git a/src/newsreader/news/collection/tests/feed/collector/tests.py b/src/newsreader/news/collection/tests/feed/collector/tests.py index 0d14730..16008dd 100644 --- a/src/newsreader/news/collection/tests/feed/collector/tests.py +++ b/src/newsreader/news/collection/tests/feed/collector/tests.py @@ -2,20 +2,13 @@ from datetime import date, datetime, time from time import struct_time from unittest.mock import MagicMock, patch +from django.test import TestCase +from django.utils import timezone + import pytz from freezegun import freeze_time -from .mocks import ( - duplicate_mock, - empty_mock, - multiple_mock, - multiple_update_mock, -) - -from django.test import TestCase -from django.utils import timezone - from newsreader.news.collection.exceptions import ( StreamDeniedException, StreamException, @@ -30,6 +23,13 @@ from newsreader.news.collection.utils import build_publication_date from newsreader.news.posts.models import Post from newsreader.news.posts.tests.factories import PostFactory +from .mocks import ( + duplicate_mock, + empty_mock, + multiple_mock, + multiple_update_mock, +) + class FeedCollectorTestCase(TestCase): def setUp(self): diff --git a/src/newsreader/news/collection/tests/feed/stream/mocks.py b/src/newsreader/news/collection/tests/feed/stream/mocks.py index a098383..9e7d796 100644 --- a/src/newsreader/news/collection/tests/feed/stream/mocks.py +++ b/src/newsreader/news/collection/tests/feed/stream/mocks.py @@ -1,5 +1,6 @@ from time import struct_time + simple_mock = { "bozo": 1, "encoding": "utf-8", diff --git a/src/newsreader/news/collection/tests/feed/stream/tests.py b/src/newsreader/news/collection/tests/feed/stream/tests.py index 3a7811b..8509156 100644 --- a/src/newsreader/news/collection/tests/feed/stream/tests.py +++ b/src/newsreader/news/collection/tests/feed/stream/tests.py @@ -1,7 +1,5 @@ from unittest.mock import MagicMock, patch -from .mocks import simple_mock - from django.test import TestCase from django.utils import timezone @@ -16,6 +14,8 @@ from newsreader.news.collection.exceptions import ( from newsreader.news.collection.feed import FeedStream from newsreader.news.collection.tests.factories import CollectionRuleFactory +from .mocks import simple_mock + class FeedStreamTestCase(TestCase): def setUp(self): diff --git a/src/newsreader/news/collection/tests/tests.py b/src/newsreader/news/collection/tests/tests.py index 08bc4e0..c39abea 100644 --- a/src/newsreader/news/collection/tests/tests.py +++ b/src/newsreader/news/collection/tests/tests.py @@ -1,11 +1,9 @@ from unittest.mock import MagicMock, patch -from bs4 import BeautifulSoup - -from .mocks import feed_mock_without_link, simple_feed_mock, simple_mock - from django.test import TestCase +from bs4 import BeautifulSoup + from newsreader.news.collection.base import URLBuilder, WebsiteStream from newsreader.news.collection.exceptions import ( StreamDeniedException, @@ -17,6 +15,8 @@ from newsreader.news.collection.exceptions import ( ) from newsreader.news.collection.tests.factories import CollectionRuleFactory +from .mocks import feed_mock_without_link, simple_feed_mock, simple_mock + class WebsiteStreamTestCase(TestCase): def setUp(self): diff --git a/src/newsreader/news/collection/tests/utils/tests.py b/src/newsreader/news/collection/tests/utils/tests.py index e2f0fcf..0362c71 100644 --- a/src/newsreader/news/collection/tests/utils/tests.py +++ b/src/newsreader/news/collection/tests/utils/tests.py @@ -1,5 +1,7 @@ from unittest.mock import MagicMock, patch +from django.test import TestCase + from requests.exceptions import ConnectionError as RequestConnectionError from requests.exceptions import ( HTTPError, @@ -8,8 +10,6 @@ from requests.exceptions import ( TooManyRedirects, ) -from django.test import TestCase - from newsreader.news.collection.exceptions import ( StreamConnectionError, StreamDeniedException, diff --git a/src/newsreader/news/collection/utils.py b/src/newsreader/news/collection/utils.py index 527587d..0abc367 100644 --- a/src/newsreader/news/collection/utils.py +++ b/src/newsreader/news/collection/utils.py @@ -2,13 +2,13 @@ from datetime import datetime, tzinfo from time import mktime, struct_time from typing import Tuple +from django.utils import timezone + import requests from requests.exceptions import RequestException from requests.models import Response -from django.utils import timezone - from newsreader.news.collection.response_handler import ResponseHandler diff --git a/src/newsreader/news/collection/views.py b/src/newsreader/news/collection/views.py index 91ea44a..dc1ba72 100644 --- a/src/newsreader/news/collection/views.py +++ b/src/newsreader/news/collection/views.py @@ -1,3 +1,4 @@ from django.shortcuts import render + # Create your views here. diff --git a/src/newsreader/news/posts/admin.py b/src/newsreader/news/posts/admin.py index 2ba7c81..3e6940b 100644 --- a/src/newsreader/news/posts/admin.py +++ b/src/newsreader/news/posts/admin.py @@ -4,26 +4,13 @@ from newsreader.news.posts.models import Category, Post class PostAdmin(admin.ModelAdmin): - list_display = ( - "publication_date", - "author", - "rule", - "title", - ) - list_display_links = ("title", ) - list_filter = ("rule", ) + list_display = ("publication_date", "author", "rule", "title") + list_display_links = ("title",) + list_filter = ("rule",) ordering = ("-publication_date", "title") - fields = ( - "title", - "body", - "author", - "publication_date", - "url", - "remote_identifier", - "category", - ) + fields = ("title", "body", "author", "publication_date", "url", "remote_identifier", "category") search_fields = ["title"] diff --git a/src/newsreader/news/posts/apps.py b/src/newsreader/news/posts/apps.py index 2c2b982..d39b3e1 100644 --- a/src/newsreader/news/posts/apps.py +++ b/src/newsreader/news/posts/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class PostsConfig(AppConfig): - name = 'posts' + name = "posts" diff --git a/src/newsreader/news/posts/migrations/0001_initial.py b/src/newsreader/news/posts/migrations/0001_initial.py index 36666b3..c7e7604 100644 --- a/src/newsreader/news/posts/migrations/0001_initial.py +++ b/src/newsreader/news/posts/migrations/0001_initial.py @@ -9,70 +9,57 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ('collection', '0001_initial'), - ] + dependencies = [("collection", "0001_initial")] operations = [ migrations.CreateModel( - name='Category', + name="Category", fields=[ ( - 'id', + "id", models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name='ID' - ) + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), ), - ('created', models.DateTimeField(auto_now_add=True)), - ('modified', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=50)), + ("created", models.DateTimeField(auto_now_add=True)), + ("modified", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=50)), ], - options={ - 'abstract': False, - }, + options={"abstract": False}, ), migrations.CreateModel( - name='Post', + name="Post", fields=[ ( - 'id', + "id", models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name='ID' - ) + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), ), - ('created', models.DateTimeField(auto_now_add=True)), - ('modified', models.DateTimeField(auto_now=True)), - ('title', models.CharField(max_length=200)), - ('body', models.TextField()), - ('source', models.CharField(max_length=200)), - ('publication_date', models.DateTimeField()), - ('url', models.URLField()), - ('remote_identifier', models.CharField(max_length=500)), + ("created", models.DateTimeField(auto_now_add=True)), + ("modified", models.DateTimeField(auto_now=True)), + ("title", models.CharField(max_length=200)), + ("body", models.TextField()), + ("source", models.CharField(max_length=200)), + ("publication_date", models.DateTimeField()), + ("url", models.URLField()), + ("remote_identifier", models.CharField(max_length=500)), ( - 'category', + "category", models.ForeignKey( blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, - to='posts.Category' - ) + to="posts.Category", + ), ), ( - 'rule', + "rule", models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to='collection.CollectionRule' - ) + on_delete=django.db.models.deletion.CASCADE, to="collection.CollectionRule" + ), ), ], - options={ - 'abstract': False, - }, + options={"abstract": False}, ), ] diff --git a/src/newsreader/news/posts/migrations/0002_auto_20190520_2206.py b/src/newsreader/news/posts/migrations/0002_auto_20190520_2206.py index cb86d51..4365972 100644 --- a/src/newsreader/news/posts/migrations/0002_auto_20190520_2206.py +++ b/src/newsreader/news/posts/migrations/0002_auto_20190520_2206.py @@ -5,16 +5,11 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('posts', '0001_initial'), - ] + dependencies = [("posts", "0001_initial")] operations = [ migrations.AlterModelOptions( - name='category', - options={ - 'verbose_name': 'Category', - 'verbose_name_plural': 'Categories' - }, - ), + name="category", + options={"verbose_name": "Category", "verbose_name_plural": "Categories"}, + ) ] diff --git a/src/newsreader/news/posts/migrations/0003_auto_20190520_2031.py b/src/newsreader/news/posts/migrations/0003_auto_20190520_2031.py index a790477..0905440 100644 --- a/src/newsreader/news/posts/migrations/0003_auto_20190520_2031.py +++ b/src/newsreader/news/posts/migrations/0003_auto_20190520_2031.py @@ -5,14 +5,10 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('posts', '0002_auto_20190520_2206'), - ] + dependencies = [("posts", "0002_auto_20190520_2206")] operations = [ migrations.AlterField( - model_name='category', - name='name', - field=models.CharField(max_length=50, unique=True), - ), + model_name="category", name="name", field=models.CharField(max_length=50, unique=True) + ) ] diff --git a/src/newsreader/news/posts/migrations/0004_auto_20190521_1941.py b/src/newsreader/news/posts/migrations/0004_auto_20190521_1941.py index a14c636..4a0ceb2 100644 --- a/src/newsreader/news/posts/migrations/0004_auto_20190521_1941.py +++ b/src/newsreader/news/posts/migrations/0004_auto_20190521_1941.py @@ -5,18 +5,13 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('posts', '0003_auto_20190520_2031'), - ] + dependencies = [("posts", "0003_auto_20190520_2031")] operations = [ - migrations.RemoveField( - model_name='post', - name='source', - ), + migrations.RemoveField(model_name="post", name="source"), migrations.AddField( - model_name='post', - name='author', + model_name="post", + name="author", field=models.CharField(blank=True, max_length=100, null=True), ), ] diff --git a/src/newsreader/news/posts/migrations/0005_auto_20190608_1054.py b/src/newsreader/news/posts/migrations/0005_auto_20190608_1054.py index 96c9d8c..9a5e03a 100644 --- a/src/newsreader/news/posts/migrations/0005_auto_20190608_1054.py +++ b/src/newsreader/news/posts/migrations/0005_auto_20190608_1054.py @@ -5,19 +5,13 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('posts', '0004_auto_20190521_1941'), - ] + dependencies = [("posts", "0004_auto_20190521_1941")] operations = [ + migrations.AlterField(model_name="post", name="body", field=models.TextField(blank=True)), migrations.AlterField( - model_name='post', - name='body', - field=models.TextField(blank=True), - ), - migrations.AlterField( - model_name='post', - name='remote_identifier', + model_name="post", + name="remote_identifier", field=models.CharField(blank=True, max_length=500, null=True), ), ] diff --git a/src/newsreader/news/posts/migrations/0006_auto_20190608_1520.py b/src/newsreader/news/posts/migrations/0006_auto_20190608_1520.py index 8215ea9..7a5f7ec 100644 --- a/src/newsreader/news/posts/migrations/0006_auto_20190608_1520.py +++ b/src/newsreader/news/posts/migrations/0006_auto_20190608_1520.py @@ -5,29 +5,23 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('posts', '0005_auto_20190608_1054'), - ] + dependencies = [("posts", "0005_auto_20190608_1054")] operations = [ migrations.AlterField( - model_name='post', - name='body', - field=models.TextField(blank=True, null=True), + model_name="post", name="body", field=models.TextField(blank=True, null=True) ), migrations.AlterField( - model_name='post', - name='publication_date', + model_name="post", + name="publication_date", field=models.DateTimeField(blank=True, null=True), ), migrations.AlterField( - model_name='post', - name='title', + model_name="post", + name="title", field=models.CharField(blank=True, max_length=200, null=True), ), migrations.AlterField( - model_name='post', - name='url', - field=models.URLField(blank=True, null=True), + model_name="post", name="url", field=models.URLField(blank=True, null=True) ), ] diff --git a/src/newsreader/news/posts/models.py b/src/newsreader/news/posts/models.py index 0528187..fd2c6c1 100644 --- a/src/newsreader/news/posts/models.py +++ b/src/newsreader/news/posts/models.py @@ -15,9 +15,7 @@ class Post(TimeStampedModel): rule = models.ForeignKey(CollectionRule, on_delete=models.CASCADE) remote_identifier = models.CharField(max_length=500, blank=True, null=True) - category = models.ForeignKey( - 'Category', blank=True, null=True, on_delete=models.PROTECT - ) + category = models.ForeignKey("Category", blank=True, null=True, on_delete=models.PROTECT) def __str__(self): return "Post-{}".format(self.pk) diff --git a/src/newsreader/news/posts/tests/factories.py b/src/newsreader/news/posts/tests/factories.py index d059335..a961b4f 100644 --- a/src/newsreader/news/posts/tests/factories.py +++ b/src/newsreader/news/posts/tests/factories.py @@ -19,8 +19,8 @@ class PostFactory(factory.django.DjangoModelFactory): title = factory.Faker("sentence") body = factory.Faker("paragraph") author = factory.Faker("name") - publication_date = factory.Faker('date_time_this_year', tzinfo=pytz.utc) - url = factory.Faker('url') + publication_date = factory.Faker("date_time_this_year", tzinfo=pytz.utc) + url = factory.Faker("url") remote_identifier = factory.Faker("url") rule = factory.SubFactory(CollectionRuleFactory) diff --git a/src/newsreader/news/posts/views.py b/src/newsreader/news/posts/views.py index 91ea44a..dc1ba72 100644 --- a/src/newsreader/news/posts/views.py +++ b/src/newsreader/news/posts/views.py @@ -1,3 +1,4 @@ from django.shortcuts import render + # Create your views here. diff --git a/src/newsreader/urls.py b/src/newsreader/urls.py index fdd5749..ed82d43 100644 --- a/src/newsreader/urls.py +++ b/src/newsreader/urls.py @@ -1,6 +1,5 @@ from django.contrib import admin from django.urls import include, path -urlpatterns = [ - path("admin/", admin.site.urls), -] + +urlpatterns = [path("admin/", admin.site.urls)] diff --git a/src/newsreader/wsgi.py b/src/newsreader/wsgi.py index 4c5ea26..ffe4b3e 100644 --- a/src/newsreader/wsgi.py +++ b/src/newsreader/wsgi.py @@ -11,6 +11,7 @@ import os from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'newsreader.settings') + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.settings") application = get_wsgi_application() From 9c6be7357d0daa9df8f2e409ccd0944e5cd04b37 Mon Sep 17 00:00:00 2001 From: Sonny Date: Mon, 8 Jul 2019 22:54:24 +0200 Subject: [PATCH 009/422] Add post/category/rule endpoints --- requirements/base.txt | 1 + requirements/dev.txt | 2 + src/manage.py | 4 +- .../{news/posts => auth}/__init__.py | 0 src/newsreader/auth/admin.py | 1 + src/newsreader/auth/apps.py | 5 + src/newsreader/auth/backends.py | 15 + .../posts => auth}/migrations/__init__.py | 0 .../tests/__init__.py => auth/models.py} | 0 src/newsreader/auth/permissions.py | 23 + src/newsreader/auth/tests/__init__.py | 0 src/newsreader/auth/tests/factories.py | 20 + src/newsreader/auth/views.py | 1 + src/newsreader/conf/base.py | 17 +- src/newsreader/conf/dev.py | 8 +- src/newsreader/core/admin.py | 3 - src/newsreader/core/models.py | 3 +- src/newsreader/core/pagination.py | 12 + src/newsreader/core/permissions.py | 6 + src/newsreader/core/tests.py | 3 - src/newsreader/core/views.py | 3 - src/newsreader/news/collection/base.py | 4 - src/newsreader/news/collection/feed.py | 5 +- .../collection/management/commands/collect.py | 2 +- .../collection/migrations/0001_initial.py | 621 +++++++++++++++++- .../migrations/0002_auto_20190410_2028.py | 16 - ...ory.py => 0002_collectionrule_category.py} | 11 +- .../migrations/0003_collectionrule_user.py | 21 + .../0004_collectionrule_timezone.py | 613 ----------------- .../migrations/0005_auto_20190521_1941.py | 22 - .../migrations/0006_collectionrule_error.py | 16 - .../migrations/0007_auto_20190623_1837.py | 16 - .../migrations/0008_auto_20190623_1847.py | 16 - .../0009_collectionrule_website_url.py | 16 - .../migrations/0010_auto_20190628_2142.py | 19 - src/newsreader/news/collection/models.py | 11 +- .../news/collection/response_handler.py | 1 - src/newsreader/news/collection/serializers.py | 21 + .../news/collection/tests/__init__.py | 4 - .../collection/tests/endpoints/__init__.py | 0 .../tests/endpoints/rules/__init__.py | 0 .../tests/endpoints/rules/detail/__init__.py | 0 .../tests/endpoints/rules/detail/tests.py | 186 ++++++ .../tests/endpoints/rules/list/__init__.py | 0 .../tests/endpoints/rules/list/tests.py | 206 ++++++ .../news/collection/tests/factories.py | 12 +- .../news/collection/tests/favicon/__init__.py | 3 - .../tests/favicon/builder/__init__.py | 1 - .../collection/tests/favicon/builder/tests.py | 2 - .../tests/favicon/client/__init__.py | 1 - .../collection/tests/favicon/client/tests.py | 1 - .../tests/favicon/collector/__init__.py | 1 - .../tests/favicon/collector/tests.py | 3 - .../news/collection/tests/feed/__init__.py | 5 - .../collection/tests/feed/builder/__init__.py | 1 - .../collection/tests/feed/builder/tests.py | 4 +- .../collection/tests/feed/client/__init__.py | 1 - .../collection/tests/feed/client/tests.py | 1 - .../tests/feed/collector/__init__.py | 1 - .../collection/tests/feed/collector/tests.py | 4 +- .../tests/feed/duplicate_handler/__init__.py | 1 - .../tests/feed/duplicate_handler/tests.py | 3 +- .../collection/tests/feed/stream/__init__.py | 1 - .../collection/tests/feed/stream/tests.py | 1 - .../news/collection/tests/utils/__init__.py | 1 - src/newsreader/news/collection/urls.py | 12 + src/newsreader/news/collection/views.py | 24 +- src/newsreader/news/core/__init__.py | 0 src/newsreader/news/{posts => core}/admin.py | 4 +- src/newsreader/news/core/apps.py | 5 + .../migrations/0001_initial.py | 38 +- .../core/migrations/0002_category_user.py | 21 + .../news/core/migrations/__init__.py | 0 src/newsreader/news/{posts => core}/models.py | 11 +- src/newsreader/news/core/pagination.py | 31 + src/newsreader/news/core/serializers.py | 39 ++ src/newsreader/news/core/tests/__init__.py | 0 .../news/core/tests/endpoints/__init__.py | 0 .../core/tests/endpoints/category/__init__.py | 0 .../endpoints/category/detail/__init__.py | 0 .../tests/endpoints/category/detail/tests.py | 168 +++++ .../tests/endpoints/category/list/__init__.py | 0 .../tests/endpoints/category/list/tests.py | 186 ++++++ .../core/tests/endpoints/post/__init__.py | 0 .../tests/endpoints/post/detail/__init__.py | 0 .../core/tests/endpoints/post/detail/tests.py | 175 +++++ .../tests/endpoints/post/list/__init__.py | 0 .../core/tests/endpoints/post/list/tests.py | 208 ++++++ .../news/{posts => core}/tests/factories.py | 17 +- src/newsreader/news/core/urls.py | 16 + src/newsreader/news/core/views.py | 52 ++ src/newsreader/news/posts/apps.py | 5 - .../migrations/0002_auto_20190520_2206.py | 15 - .../migrations/0003_auto_20190520_2031.py | 14 - .../migrations/0004_auto_20190521_1941.py | 17 - .../migrations/0005_auto_20190608_1054.py | 17 - .../migrations/0006_auto_20190608_1520.py | 27 - src/newsreader/news/posts/views.py | 4 - src/newsreader/urls.py | 17 +- 99 files changed, 2174 insertions(+), 951 deletions(-) rename src/newsreader/{news/posts => auth}/__init__.py (100%) create mode 100644 src/newsreader/auth/admin.py create mode 100644 src/newsreader/auth/apps.py create mode 100644 src/newsreader/auth/backends.py rename src/newsreader/{news/posts => auth}/migrations/__init__.py (100%) rename src/newsreader/{news/posts/tests/__init__.py => auth/models.py} (100%) create mode 100644 src/newsreader/auth/permissions.py create mode 100644 src/newsreader/auth/tests/__init__.py create mode 100644 src/newsreader/auth/tests/factories.py create mode 100644 src/newsreader/auth/views.py create mode 100644 src/newsreader/core/pagination.py create mode 100644 src/newsreader/core/permissions.py delete mode 100644 src/newsreader/news/collection/migrations/0002_auto_20190410_2028.py rename src/newsreader/news/collection/migrations/{0003_collectionrule_category.py => 0002_collectionrule_category.py} (74%) create mode 100644 src/newsreader/news/collection/migrations/0003_collectionrule_user.py delete mode 100644 src/newsreader/news/collection/migrations/0004_collectionrule_timezone.py delete mode 100644 src/newsreader/news/collection/migrations/0005_auto_20190521_1941.py delete mode 100644 src/newsreader/news/collection/migrations/0006_collectionrule_error.py delete mode 100644 src/newsreader/news/collection/migrations/0007_auto_20190623_1837.py delete mode 100644 src/newsreader/news/collection/migrations/0008_auto_20190623_1847.py delete mode 100644 src/newsreader/news/collection/migrations/0009_collectionrule_website_url.py delete mode 100644 src/newsreader/news/collection/migrations/0010_auto_20190628_2142.py create mode 100644 src/newsreader/news/collection/serializers.py create mode 100644 src/newsreader/news/collection/tests/endpoints/__init__.py create mode 100644 src/newsreader/news/collection/tests/endpoints/rules/__init__.py create mode 100644 src/newsreader/news/collection/tests/endpoints/rules/detail/__init__.py create mode 100644 src/newsreader/news/collection/tests/endpoints/rules/detail/tests.py create mode 100644 src/newsreader/news/collection/tests/endpoints/rules/list/__init__.py create mode 100644 src/newsreader/news/collection/tests/endpoints/rules/list/tests.py create mode 100644 src/newsreader/news/collection/urls.py create mode 100644 src/newsreader/news/core/__init__.py rename src/newsreader/news/{posts => core}/admin.py (85%) create mode 100644 src/newsreader/news/core/apps.py rename src/newsreader/news/{posts => core}/migrations/0001_initial.py (54%) create mode 100644 src/newsreader/news/core/migrations/0002_category_user.py create mode 100644 src/newsreader/news/core/migrations/__init__.py rename src/newsreader/news/{posts => core}/models.py (78%) create mode 100644 src/newsreader/news/core/pagination.py create mode 100644 src/newsreader/news/core/serializers.py create mode 100644 src/newsreader/news/core/tests/__init__.py create mode 100644 src/newsreader/news/core/tests/endpoints/__init__.py create mode 100644 src/newsreader/news/core/tests/endpoints/category/__init__.py create mode 100644 src/newsreader/news/core/tests/endpoints/category/detail/__init__.py create mode 100644 src/newsreader/news/core/tests/endpoints/category/detail/tests.py create mode 100644 src/newsreader/news/core/tests/endpoints/category/list/__init__.py create mode 100644 src/newsreader/news/core/tests/endpoints/category/list/tests.py create mode 100644 src/newsreader/news/core/tests/endpoints/post/__init__.py create mode 100644 src/newsreader/news/core/tests/endpoints/post/detail/__init__.py create mode 100644 src/newsreader/news/core/tests/endpoints/post/detail/tests.py create mode 100644 src/newsreader/news/core/tests/endpoints/post/list/__init__.py create mode 100644 src/newsreader/news/core/tests/endpoints/post/list/tests.py rename src/newsreader/news/{posts => core}/tests/factories.py (68%) create mode 100644 src/newsreader/news/core/urls.py create mode 100644 src/newsreader/news/core/views.py delete mode 100644 src/newsreader/news/posts/apps.py delete mode 100644 src/newsreader/news/posts/migrations/0002_auto_20190520_2206.py delete mode 100644 src/newsreader/news/posts/migrations/0003_auto_20190520_2031.py delete mode 100644 src/newsreader/news/posts/migrations/0004_auto_20190521_1941.py delete mode 100644 src/newsreader/news/posts/migrations/0005_auto_20190608_1054.py delete mode 100644 src/newsreader/news/posts/migrations/0006_auto_20190608_1520.py delete mode 100644 src/newsreader/news/posts/views.py diff --git a/requirements/base.txt b/requirements/base.txt index 00667bb..05be61f 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -3,6 +3,7 @@ beautifulsoup4==4.7.1 certifi==2019.3.9 chardet==3.0.4 Django==2.2 +djangorestframework==3.9.4 lxml==4.3.4 feedparser==5.2.1 idna==2.8 diff --git a/requirements/dev.txt b/requirements/dev.txt index 0a685e3..dbf9e15 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -2,3 +2,5 @@ factory-boy==2.12.0 freezegun==0.3.12 +django-debug-toolbar==2.0 +django-extensions==2.1.9 diff --git a/src/manage.py b/src/manage.py index a30fa10..45fc02f 100755 --- a/src/manage.py +++ b/src/manage.py @@ -5,7 +5,7 @@ import sys def main(): - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'newsreader.conf.base') + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.dev") try: from django.core.management import execute_from_command_line except ImportError as exc: @@ -17,5 +17,5 @@ def main(): execute_from_command_line(sys.argv) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/src/newsreader/news/posts/__init__.py b/src/newsreader/auth/__init__.py similarity index 100% rename from src/newsreader/news/posts/__init__.py rename to src/newsreader/auth/__init__.py diff --git a/src/newsreader/auth/admin.py b/src/newsreader/auth/admin.py new file mode 100644 index 0000000..846f6b4 --- /dev/null +++ b/src/newsreader/auth/admin.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/src/newsreader/auth/apps.py b/src/newsreader/auth/apps.py new file mode 100644 index 0000000..c467d4e --- /dev/null +++ b/src/newsreader/auth/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AuthConfig(AppConfig): + name = "auth" diff --git a/src/newsreader/auth/backends.py b/src/newsreader/auth/backends.py new file mode 100644 index 0000000..30a78b9 --- /dev/null +++ b/src/newsreader/auth/backends.py @@ -0,0 +1,15 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend + + +class EmailBackend(ModelBackend): + def authenticate(self, request, username=None, password=None, **kwargs): + user_model_class = get_user_model() + + try: + user = user_model_class.objects.get(email=username) + except user_model_class.DoesNotExist: + return + + if user.check_password(password) and self.user_can_authenticate(user): + return user diff --git a/src/newsreader/news/posts/migrations/__init__.py b/src/newsreader/auth/migrations/__init__.py similarity index 100% rename from src/newsreader/news/posts/migrations/__init__.py rename to src/newsreader/auth/migrations/__init__.py diff --git a/src/newsreader/news/posts/tests/__init__.py b/src/newsreader/auth/models.py similarity index 100% rename from src/newsreader/news/posts/tests/__init__.py rename to src/newsreader/auth/models.py diff --git a/src/newsreader/auth/permissions.py b/src/newsreader/auth/permissions.py new file mode 100644 index 0000000..2c6cf25 --- /dev/null +++ b/src/newsreader/auth/permissions.py @@ -0,0 +1,23 @@ +from rest_framework.permissions import BasePermission + + +class IsOwner(BasePermission): + def has_object_permission(self, request, view, obj): + if hasattr(obj, "user"): + return obj.user == request.user + + +class IsPostOwner(BasePermission): + def has_object_permission(self, request, view, obj): + is_category_user = False + is_rule_user = False + rule = obj.rule + + if rule and rule.user: + is_rule_user = bool(rule.user == request.user) + + if rule.category and rule.category.user: + is_category_user = bool(rule.category.user == request.user) + return bool(is_category_user and is_rule_user) + + return is_rule_user diff --git a/src/newsreader/auth/tests/__init__.py b/src/newsreader/auth/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/auth/tests/factories.py b/src/newsreader/auth/tests/factories.py new file mode 100644 index 0000000..3975f62 --- /dev/null +++ b/src/newsreader/auth/tests/factories.py @@ -0,0 +1,20 @@ +from django.contrib.auth.models import User + +import factory + + +class UserFactory(factory.django.DjangoModelFactory): + username = factory.Sequence(lambda n: f"user-{n}") + email = factory.LazyAttribute(lambda o: f"{o.username}@example.org") + password = factory.Faker("password") + + is_staff = False + is_active = True + + @classmethod + def _create(cls, model_class, *args, **kwargs): + manager = cls._get_manager(model_class) + return manager.create_user(*args, **kwargs) + + class Meta: + model = User diff --git a/src/newsreader/auth/views.py b/src/newsreader/auth/views.py new file mode 100644 index 0000000..60f00ef --- /dev/null +++ b/src/newsreader/auth/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 16927e4..593c42c 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -26,6 +26,7 @@ SECRET_KEY = "^!7a2jq5j!exc-55vf$anx9^6ff6=u_ub5=5p1(1x47fix)syh" DEBUG = False ALLOWED_HOSTS = ["127.0.0.1"] +INTERNAL_IPS = ["127.0.0.1"] # Application definition INSTALLED_APPS = [ @@ -35,9 +36,11 @@ INSTALLED_APPS = [ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + # third party apps + "rest_framework", # app modules + "newsreader.news.core", "newsreader.news.collection", - "newsreader.news.posts", ] MIDDLEWARE = [ @@ -89,6 +92,9 @@ AUTH_PASSWORD_VALIDATORS = [ {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] +# Authentication +AUTHENTICATION_BACKENDS = ["newsreader.auth.backends.EmailBackend"] + # Internationalization # https://docs.djangoproject.com/en/2.2/topics/i18n/ LANGUAGE_CODE = "en-us" @@ -101,3 +107,12 @@ USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/2.2/howto/static-files/ STATIC_URL = "/static/" + +# Third party settings +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework.authentication.SessionAuthentication",), + "DEFAULT_PERMISSION_CLASSES": ( + "rest_framework.permissions.IsAuthenticated", + "newsreader.auth.permissions.IsOwner", + ), +} diff --git a/src/newsreader/conf/dev.py b/src/newsreader/conf/dev.py index 245a78c..7453d52 100644 --- a/src/newsreader/conf/dev.py +++ b/src/newsreader/conf/dev.py @@ -1,13 +1,15 @@ from .base import * -# Development settings - DEBUG = True +MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] + EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +INSTALLED_APPS += ["debug_toolbar", "django_extensions"] + try: - from .local import * + pass except ImportError: pass diff --git a/src/newsreader/core/admin.py b/src/newsreader/core/admin.py index a011d19..846f6b4 100644 --- a/src/newsreader/core/admin.py +++ b/src/newsreader/core/admin.py @@ -1,4 +1 @@ -from django.contrib import admin - - # Register your models here. diff --git a/src/newsreader/core/models.py b/src/newsreader/core/models.py index 2e696fb..f8bd80f 100644 --- a/src/newsreader/core/models.py +++ b/src/newsreader/core/models.py @@ -1,4 +1,5 @@ from django.db import models +from django.utils import timezone class TimeStampedModel(models.Model): @@ -7,7 +8,7 @@ class TimeStampedModel(models.Model): updating ``created`` and ``modified`` fields. """ - created = models.DateTimeField(auto_now_add=True) + created = models.DateTimeField(default=timezone.now) modified = models.DateTimeField(auto_now=True) class Meta: diff --git a/src/newsreader/core/pagination.py b/src/newsreader/core/pagination.py new file mode 100644 index 0000000..5e19771 --- /dev/null +++ b/src/newsreader/core/pagination.py @@ -0,0 +1,12 @@ +from rest_framework.pagination import PageNumberPagination + + +class ResultSetPagination(PageNumberPagination): + page_size_query_param = "count" + max_page_size = 50 + page_size = 30 + + +class LargeResultSetPagination(ResultSetPagination): + max_page_size = 100 + page_size = 50 diff --git a/src/newsreader/core/permissions.py b/src/newsreader/core/permissions.py new file mode 100644 index 0000000..b584b63 --- /dev/null +++ b/src/newsreader/core/permissions.py @@ -0,0 +1,6 @@ +from rest_framework.permissions import BasePermission + + +class IsOwner(BasePermission): + def has_object_permission(self, request, view, obj): + return obj.user == request.user diff --git a/src/newsreader/core/tests.py b/src/newsreader/core/tests.py index 7c72b39..a39b155 100644 --- a/src/newsreader/core/tests.py +++ b/src/newsreader/core/tests.py @@ -1,4 +1 @@ -from django.test import TestCase - - # Create your tests here. diff --git a/src/newsreader/core/views.py b/src/newsreader/core/views.py index dc1ba72..60f00ef 100644 --- a/src/newsreader/core/views.py +++ b/src/newsreader/core/views.py @@ -1,4 +1 @@ -from django.shortcuts import render - - # Create your views here. diff --git a/src/newsreader/news/collection/base.py b/src/newsreader/news/collection/base.py index c202df8..1b80256 100644 --- a/src/newsreader/news/collection/base.py +++ b/src/newsreader/news/collection/base.py @@ -1,9 +1,5 @@ from typing import ContextManager, Dict, List, Optional, Tuple -from django.utils import timezone - -import requests - from bs4 import BeautifulSoup from newsreader.news.collection.exceptions import StreamParseException diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index 2cf248c..a7ab2fc 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -17,9 +17,8 @@ from newsreader.news.collection.exceptions import ( StreamTimeOutException, ) from newsreader.news.collection.models import CollectionRule -from newsreader.news.collection.response_handler import ResponseHandler from newsreader.news.collection.utils import build_publication_date, fetch -from newsreader.news.posts.models import Post +from newsreader.news.core.models import Post class FeedBuilder(Builder): @@ -62,7 +61,7 @@ class FeedBuilder(Builder): tz = pytz.timezone(rule.timezone) for entry in entries: - data = {"rule_id": rule.pk, "category": rule.category} + data = {"rule_id": rule.pk} for field, value in field_mapping.items(): if field in entry: diff --git a/src/newsreader/news/collection/management/commands/collect.py b/src/newsreader/news/collection/management/commands/collect.py index 855089f..3da9905 100644 --- a/src/newsreader/news/collection/management/commands/collect.py +++ b/src/newsreader/news/collection/management/commands/collect.py @@ -1,4 +1,4 @@ -from django.core.management.base import BaseCommand, CommandError +from django.core.management.base import BaseCommand from newsreader.news.collection.feed import FeedCollector from newsreader.news.collection.models import CollectionRule diff --git a/src/newsreader/news/collection/migrations/0001_initial.py b/src/newsreader/news/collection/migrations/0001_initial.py index 1091b7a..00d3d31 100644 --- a/src/newsreader/news/collection/migrations/0001_initial.py +++ b/src/newsreader/news/collection/migrations/0001_initial.py @@ -1,4 +1,6 @@ -# Generated by Django 2.2 on 2019-04-10 20:10 +# Generated by Django 2.2 on 2019-07-05 20:59 + +import django.utils.timezone from django.db import migrations, models @@ -19,10 +21,623 @@ class Migration(migrations.Migration): auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), + ("created", models.DateTimeField(default=django.utils.timezone.now)), + ("modified", models.DateTimeField(auto_now=True)), ("name", models.CharField(max_length=100)), - ("url", models.URLField()), - ("last_suceeded", models.DateTimeField()), + ("url", models.URLField(max_length=1024)), + ( + "website_url", + models.URLField(blank=True, editable=False, max_length=1024, null=True), + ), + ("favicon", models.URLField(blank=True, null=True)), + ( + "timezone", + models.CharField( + choices=[ + ("Africa/Abidjan", "Africa/Abidjan"), + ("Africa/Accra", "Africa/Accra"), + ("Africa/Addis_Ababa", "Africa/Addis_Ababa"), + ("Africa/Algiers", "Africa/Algiers"), + ("Africa/Asmara", "Africa/Asmara"), + ("Africa/Asmera", "Africa/Asmera"), + ("Africa/Bamako", "Africa/Bamako"), + ("Africa/Bangui", "Africa/Bangui"), + ("Africa/Banjul", "Africa/Banjul"), + ("Africa/Bissau", "Africa/Bissau"), + ("Africa/Blantyre", "Africa/Blantyre"), + ("Africa/Brazzaville", "Africa/Brazzaville"), + ("Africa/Bujumbura", "Africa/Bujumbura"), + ("Africa/Cairo", "Africa/Cairo"), + ("Africa/Casablanca", "Africa/Casablanca"), + ("Africa/Ceuta", "Africa/Ceuta"), + ("Africa/Conakry", "Africa/Conakry"), + ("Africa/Dakar", "Africa/Dakar"), + ("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"), + ("Africa/Djibouti", "Africa/Djibouti"), + ("Africa/Douala", "Africa/Douala"), + ("Africa/El_Aaiun", "Africa/El_Aaiun"), + ("Africa/Freetown", "Africa/Freetown"), + ("Africa/Gaborone", "Africa/Gaborone"), + ("Africa/Harare", "Africa/Harare"), + ("Africa/Johannesburg", "Africa/Johannesburg"), + ("Africa/Juba", "Africa/Juba"), + ("Africa/Kampala", "Africa/Kampala"), + ("Africa/Khartoum", "Africa/Khartoum"), + ("Africa/Kigali", "Africa/Kigali"), + ("Africa/Kinshasa", "Africa/Kinshasa"), + ("Africa/Lagos", "Africa/Lagos"), + ("Africa/Libreville", "Africa/Libreville"), + ("Africa/Lome", "Africa/Lome"), + ("Africa/Luanda", "Africa/Luanda"), + ("Africa/Lubumbashi", "Africa/Lubumbashi"), + ("Africa/Lusaka", "Africa/Lusaka"), + ("Africa/Malabo", "Africa/Malabo"), + ("Africa/Maputo", "Africa/Maputo"), + ("Africa/Maseru", "Africa/Maseru"), + ("Africa/Mbabane", "Africa/Mbabane"), + ("Africa/Mogadishu", "Africa/Mogadishu"), + ("Africa/Monrovia", "Africa/Monrovia"), + ("Africa/Nairobi", "Africa/Nairobi"), + ("Africa/Ndjamena", "Africa/Ndjamena"), + ("Africa/Niamey", "Africa/Niamey"), + ("Africa/Nouakchott", "Africa/Nouakchott"), + ("Africa/Ouagadougou", "Africa/Ouagadougou"), + ("Africa/Porto-Novo", "Africa/Porto-Novo"), + ("Africa/Sao_Tome", "Africa/Sao_Tome"), + ("Africa/Timbuktu", "Africa/Timbuktu"), + ("Africa/Tripoli", "Africa/Tripoli"), + ("Africa/Tunis", "Africa/Tunis"), + ("Africa/Windhoek", "Africa/Windhoek"), + ("America/Adak", "America/Adak"), + ("America/Anchorage", "America/Anchorage"), + ("America/Anguilla", "America/Anguilla"), + ("America/Antigua", "America/Antigua"), + ("America/Araguaina", "America/Araguaina"), + ("America/Argentina/Buenos_Aires", "America/Argentina/Buenos_Aires"), + ("America/Argentina/Catamarca", "America/Argentina/Catamarca"), + ( + "America/Argentina/ComodRivadavia", + "America/Argentina/ComodRivadavia", + ), + ("America/Argentina/Cordoba", "America/Argentina/Cordoba"), + ("America/Argentina/Jujuy", "America/Argentina/Jujuy"), + ("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"), + ("America/Argentina/Mendoza", "America/Argentina/Mendoza"), + ("America/Argentina/Rio_Gallegos", "America/Argentina/Rio_Gallegos"), + ("America/Argentina/Salta", "America/Argentina/Salta"), + ("America/Argentina/San_Juan", "America/Argentina/San_Juan"), + ("America/Argentina/San_Luis", "America/Argentina/San_Luis"), + ("America/Argentina/Tucuman", "America/Argentina/Tucuman"), + ("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"), + ("America/Aruba", "America/Aruba"), + ("America/Asuncion", "America/Asuncion"), + ("America/Atikokan", "America/Atikokan"), + ("America/Atka", "America/Atka"), + ("America/Bahia", "America/Bahia"), + ("America/Bahia_Banderas", "America/Bahia_Banderas"), + ("America/Barbados", "America/Barbados"), + ("America/Belem", "America/Belem"), + ("America/Belize", "America/Belize"), + ("America/Blanc-Sablon", "America/Blanc-Sablon"), + ("America/Boa_Vista", "America/Boa_Vista"), + ("America/Bogota", "America/Bogota"), + ("America/Boise", "America/Boise"), + ("America/Buenos_Aires", "America/Buenos_Aires"), + ("America/Cambridge_Bay", "America/Cambridge_Bay"), + ("America/Campo_Grande", "America/Campo_Grande"), + ("America/Cancun", "America/Cancun"), + ("America/Caracas", "America/Caracas"), + ("America/Catamarca", "America/Catamarca"), + ("America/Cayenne", "America/Cayenne"), + ("America/Cayman", "America/Cayman"), + ("America/Chicago", "America/Chicago"), + ("America/Chihuahua", "America/Chihuahua"), + ("America/Coral_Harbour", "America/Coral_Harbour"), + ("America/Cordoba", "America/Cordoba"), + ("America/Costa_Rica", "America/Costa_Rica"), + ("America/Creston", "America/Creston"), + ("America/Cuiaba", "America/Cuiaba"), + ("America/Curacao", "America/Curacao"), + ("America/Danmarkshavn", "America/Danmarkshavn"), + ("America/Dawson", "America/Dawson"), + ("America/Dawson_Creek", "America/Dawson_Creek"), + ("America/Denver", "America/Denver"), + ("America/Detroit", "America/Detroit"), + ("America/Dominica", "America/Dominica"), + ("America/Edmonton", "America/Edmonton"), + ("America/Eirunepe", "America/Eirunepe"), + ("America/El_Salvador", "America/El_Salvador"), + ("America/Ensenada", "America/Ensenada"), + ("America/Fort_Nelson", "America/Fort_Nelson"), + ("America/Fort_Wayne", "America/Fort_Wayne"), + ("America/Fortaleza", "America/Fortaleza"), + ("America/Glace_Bay", "America/Glace_Bay"), + ("America/Godthab", "America/Godthab"), + ("America/Goose_Bay", "America/Goose_Bay"), + ("America/Grand_Turk", "America/Grand_Turk"), + ("America/Grenada", "America/Grenada"), + ("America/Guadeloupe", "America/Guadeloupe"), + ("America/Guatemala", "America/Guatemala"), + ("America/Guayaquil", "America/Guayaquil"), + ("America/Guyana", "America/Guyana"), + ("America/Halifax", "America/Halifax"), + ("America/Havana", "America/Havana"), + ("America/Hermosillo", "America/Hermosillo"), + ("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"), + ("America/Indiana/Knox", "America/Indiana/Knox"), + ("America/Indiana/Marengo", "America/Indiana/Marengo"), + ("America/Indiana/Petersburg", "America/Indiana/Petersburg"), + ("America/Indiana/Tell_City", "America/Indiana/Tell_City"), + ("America/Indiana/Vevay", "America/Indiana/Vevay"), + ("America/Indiana/Vincennes", "America/Indiana/Vincennes"), + ("America/Indiana/Winamac", "America/Indiana/Winamac"), + ("America/Indianapolis", "America/Indianapolis"), + ("America/Inuvik", "America/Inuvik"), + ("America/Iqaluit", "America/Iqaluit"), + ("America/Jamaica", "America/Jamaica"), + ("America/Jujuy", "America/Jujuy"), + ("America/Juneau", "America/Juneau"), + ("America/Kentucky/Louisville", "America/Kentucky/Louisville"), + ("America/Kentucky/Monticello", "America/Kentucky/Monticello"), + ("America/Knox_IN", "America/Knox_IN"), + ("America/Kralendijk", "America/Kralendijk"), + ("America/La_Paz", "America/La_Paz"), + ("America/Lima", "America/Lima"), + ("America/Los_Angeles", "America/Los_Angeles"), + ("America/Louisville", "America/Louisville"), + ("America/Lower_Princes", "America/Lower_Princes"), + ("America/Maceio", "America/Maceio"), + ("America/Managua", "America/Managua"), + ("America/Manaus", "America/Manaus"), + ("America/Marigot", "America/Marigot"), + ("America/Martinique", "America/Martinique"), + ("America/Matamoros", "America/Matamoros"), + ("America/Mazatlan", "America/Mazatlan"), + ("America/Mendoza", "America/Mendoza"), + ("America/Menominee", "America/Menominee"), + ("America/Merida", "America/Merida"), + ("America/Metlakatla", "America/Metlakatla"), + ("America/Mexico_City", "America/Mexico_City"), + ("America/Miquelon", "America/Miquelon"), + ("America/Moncton", "America/Moncton"), + ("America/Monterrey", "America/Monterrey"), + ("America/Montevideo", "America/Montevideo"), + ("America/Montreal", "America/Montreal"), + ("America/Montserrat", "America/Montserrat"), + ("America/Nassau", "America/Nassau"), + ("America/New_York", "America/New_York"), + ("America/Nipigon", "America/Nipigon"), + ("America/Nome", "America/Nome"), + ("America/Noronha", "America/Noronha"), + ("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"), + ("America/North_Dakota/Center", "America/North_Dakota/Center"), + ("America/North_Dakota/New_Salem", "America/North_Dakota/New_Salem"), + ("America/Ojinaga", "America/Ojinaga"), + ("America/Panama", "America/Panama"), + ("America/Pangnirtung", "America/Pangnirtung"), + ("America/Paramaribo", "America/Paramaribo"), + ("America/Phoenix", "America/Phoenix"), + ("America/Port-au-Prince", "America/Port-au-Prince"), + ("America/Port_of_Spain", "America/Port_of_Spain"), + ("America/Porto_Acre", "America/Porto_Acre"), + ("America/Porto_Velho", "America/Porto_Velho"), + ("America/Puerto_Rico", "America/Puerto_Rico"), + ("America/Punta_Arenas", "America/Punta_Arenas"), + ("America/Rainy_River", "America/Rainy_River"), + ("America/Rankin_Inlet", "America/Rankin_Inlet"), + ("America/Recife", "America/Recife"), + ("America/Regina", "America/Regina"), + ("America/Resolute", "America/Resolute"), + ("America/Rio_Branco", "America/Rio_Branco"), + ("America/Rosario", "America/Rosario"), + ("America/Santa_Isabel", "America/Santa_Isabel"), + ("America/Santarem", "America/Santarem"), + ("America/Santiago", "America/Santiago"), + ("America/Santo_Domingo", "America/Santo_Domingo"), + ("America/Sao_Paulo", "America/Sao_Paulo"), + ("America/Scoresbysund", "America/Scoresbysund"), + ("America/Shiprock", "America/Shiprock"), + ("America/Sitka", "America/Sitka"), + ("America/St_Barthelemy", "America/St_Barthelemy"), + ("America/St_Johns", "America/St_Johns"), + ("America/St_Kitts", "America/St_Kitts"), + ("America/St_Lucia", "America/St_Lucia"), + ("America/St_Thomas", "America/St_Thomas"), + ("America/St_Vincent", "America/St_Vincent"), + ("America/Swift_Current", "America/Swift_Current"), + ("America/Tegucigalpa", "America/Tegucigalpa"), + ("America/Thule", "America/Thule"), + ("America/Thunder_Bay", "America/Thunder_Bay"), + ("America/Tijuana", "America/Tijuana"), + ("America/Toronto", "America/Toronto"), + ("America/Tortola", "America/Tortola"), + ("America/Vancouver", "America/Vancouver"), + ("America/Virgin", "America/Virgin"), + ("America/Whitehorse", "America/Whitehorse"), + ("America/Winnipeg", "America/Winnipeg"), + ("America/Yakutat", "America/Yakutat"), + ("America/Yellowknife", "America/Yellowknife"), + ("Antarctica/Casey", "Antarctica/Casey"), + ("Antarctica/Davis", "Antarctica/Davis"), + ("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"), + ("Antarctica/Macquarie", "Antarctica/Macquarie"), + ("Antarctica/Mawson", "Antarctica/Mawson"), + ("Antarctica/McMurdo", "Antarctica/McMurdo"), + ("Antarctica/Palmer", "Antarctica/Palmer"), + ("Antarctica/Rothera", "Antarctica/Rothera"), + ("Antarctica/South_Pole", "Antarctica/South_Pole"), + ("Antarctica/Syowa", "Antarctica/Syowa"), + ("Antarctica/Troll", "Antarctica/Troll"), + ("Antarctica/Vostok", "Antarctica/Vostok"), + ("Arctic/Longyearbyen", "Arctic/Longyearbyen"), + ("Asia/Aden", "Asia/Aden"), + ("Asia/Almaty", "Asia/Almaty"), + ("Asia/Amman", "Asia/Amman"), + ("Asia/Anadyr", "Asia/Anadyr"), + ("Asia/Aqtau", "Asia/Aqtau"), + ("Asia/Aqtobe", "Asia/Aqtobe"), + ("Asia/Ashgabat", "Asia/Ashgabat"), + ("Asia/Ashkhabad", "Asia/Ashkhabad"), + ("Asia/Atyrau", "Asia/Atyrau"), + ("Asia/Baghdad", "Asia/Baghdad"), + ("Asia/Bahrain", "Asia/Bahrain"), + ("Asia/Baku", "Asia/Baku"), + ("Asia/Bangkok", "Asia/Bangkok"), + ("Asia/Barnaul", "Asia/Barnaul"), + ("Asia/Beirut", "Asia/Beirut"), + ("Asia/Bishkek", "Asia/Bishkek"), + ("Asia/Brunei", "Asia/Brunei"), + ("Asia/Calcutta", "Asia/Calcutta"), + ("Asia/Chita", "Asia/Chita"), + ("Asia/Choibalsan", "Asia/Choibalsan"), + ("Asia/Chongqing", "Asia/Chongqing"), + ("Asia/Chungking", "Asia/Chungking"), + ("Asia/Colombo", "Asia/Colombo"), + ("Asia/Dacca", "Asia/Dacca"), + ("Asia/Damascus", "Asia/Damascus"), + ("Asia/Dhaka", "Asia/Dhaka"), + ("Asia/Dili", "Asia/Dili"), + ("Asia/Dubai", "Asia/Dubai"), + ("Asia/Dushanbe", "Asia/Dushanbe"), + ("Asia/Famagusta", "Asia/Famagusta"), + ("Asia/Gaza", "Asia/Gaza"), + ("Asia/Harbin", "Asia/Harbin"), + ("Asia/Hebron", "Asia/Hebron"), + ("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"), + ("Asia/Hong_Kong", "Asia/Hong_Kong"), + ("Asia/Hovd", "Asia/Hovd"), + ("Asia/Irkutsk", "Asia/Irkutsk"), + ("Asia/Istanbul", "Asia/Istanbul"), + ("Asia/Jakarta", "Asia/Jakarta"), + ("Asia/Jayapura", "Asia/Jayapura"), + ("Asia/Jerusalem", "Asia/Jerusalem"), + ("Asia/Kabul", "Asia/Kabul"), + ("Asia/Kamchatka", "Asia/Kamchatka"), + ("Asia/Karachi", "Asia/Karachi"), + ("Asia/Kashgar", "Asia/Kashgar"), + ("Asia/Kathmandu", "Asia/Kathmandu"), + ("Asia/Katmandu", "Asia/Katmandu"), + ("Asia/Khandyga", "Asia/Khandyga"), + ("Asia/Kolkata", "Asia/Kolkata"), + ("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"), + ("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"), + ("Asia/Kuching", "Asia/Kuching"), + ("Asia/Kuwait", "Asia/Kuwait"), + ("Asia/Macao", "Asia/Macao"), + ("Asia/Macau", "Asia/Macau"), + ("Asia/Magadan", "Asia/Magadan"), + ("Asia/Makassar", "Asia/Makassar"), + ("Asia/Manila", "Asia/Manila"), + ("Asia/Muscat", "Asia/Muscat"), + ("Asia/Nicosia", "Asia/Nicosia"), + ("Asia/Novokuznetsk", "Asia/Novokuznetsk"), + ("Asia/Novosibirsk", "Asia/Novosibirsk"), + ("Asia/Omsk", "Asia/Omsk"), + ("Asia/Oral", "Asia/Oral"), + ("Asia/Phnom_Penh", "Asia/Phnom_Penh"), + ("Asia/Pontianak", "Asia/Pontianak"), + ("Asia/Pyongyang", "Asia/Pyongyang"), + ("Asia/Qatar", "Asia/Qatar"), + ("Asia/Qostanay", "Asia/Qostanay"), + ("Asia/Qyzylorda", "Asia/Qyzylorda"), + ("Asia/Rangoon", "Asia/Rangoon"), + ("Asia/Riyadh", "Asia/Riyadh"), + ("Asia/Saigon", "Asia/Saigon"), + ("Asia/Sakhalin", "Asia/Sakhalin"), + ("Asia/Samarkand", "Asia/Samarkand"), + ("Asia/Seoul", "Asia/Seoul"), + ("Asia/Shanghai", "Asia/Shanghai"), + ("Asia/Singapore", "Asia/Singapore"), + ("Asia/Srednekolymsk", "Asia/Srednekolymsk"), + ("Asia/Taipei", "Asia/Taipei"), + ("Asia/Tashkent", "Asia/Tashkent"), + ("Asia/Tbilisi", "Asia/Tbilisi"), + ("Asia/Tehran", "Asia/Tehran"), + ("Asia/Tel_Aviv", "Asia/Tel_Aviv"), + ("Asia/Thimbu", "Asia/Thimbu"), + ("Asia/Thimphu", "Asia/Thimphu"), + ("Asia/Tokyo", "Asia/Tokyo"), + ("Asia/Tomsk", "Asia/Tomsk"), + ("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"), + ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"), + ("Asia/Ulan_Bator", "Asia/Ulan_Bator"), + ("Asia/Urumqi", "Asia/Urumqi"), + ("Asia/Ust-Nera", "Asia/Ust-Nera"), + ("Asia/Vientiane", "Asia/Vientiane"), + ("Asia/Vladivostok", "Asia/Vladivostok"), + ("Asia/Yakutsk", "Asia/Yakutsk"), + ("Asia/Yangon", "Asia/Yangon"), + ("Asia/Yekaterinburg", "Asia/Yekaterinburg"), + ("Asia/Yerevan", "Asia/Yerevan"), + ("Atlantic/Azores", "Atlantic/Azores"), + ("Atlantic/Bermuda", "Atlantic/Bermuda"), + ("Atlantic/Canary", "Atlantic/Canary"), + ("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"), + ("Atlantic/Faeroe", "Atlantic/Faeroe"), + ("Atlantic/Faroe", "Atlantic/Faroe"), + ("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"), + ("Atlantic/Madeira", "Atlantic/Madeira"), + ("Atlantic/Reykjavik", "Atlantic/Reykjavik"), + ("Atlantic/South_Georgia", "Atlantic/South_Georgia"), + ("Atlantic/St_Helena", "Atlantic/St_Helena"), + ("Atlantic/Stanley", "Atlantic/Stanley"), + ("Australia/ACT", "Australia/ACT"), + ("Australia/Adelaide", "Australia/Adelaide"), + ("Australia/Brisbane", "Australia/Brisbane"), + ("Australia/Broken_Hill", "Australia/Broken_Hill"), + ("Australia/Canberra", "Australia/Canberra"), + ("Australia/Currie", "Australia/Currie"), + ("Australia/Darwin", "Australia/Darwin"), + ("Australia/Eucla", "Australia/Eucla"), + ("Australia/Hobart", "Australia/Hobart"), + ("Australia/LHI", "Australia/LHI"), + ("Australia/Lindeman", "Australia/Lindeman"), + ("Australia/Lord_Howe", "Australia/Lord_Howe"), + ("Australia/Melbourne", "Australia/Melbourne"), + ("Australia/NSW", "Australia/NSW"), + ("Australia/North", "Australia/North"), + ("Australia/Perth", "Australia/Perth"), + ("Australia/Queensland", "Australia/Queensland"), + ("Australia/South", "Australia/South"), + ("Australia/Sydney", "Australia/Sydney"), + ("Australia/Tasmania", "Australia/Tasmania"), + ("Australia/Victoria", "Australia/Victoria"), + ("Australia/West", "Australia/West"), + ("Australia/Yancowinna", "Australia/Yancowinna"), + ("Brazil/Acre", "Brazil/Acre"), + ("Brazil/DeNoronha", "Brazil/DeNoronha"), + ("Brazil/East", "Brazil/East"), + ("Brazil/West", "Brazil/West"), + ("CET", "CET"), + ("CST6CDT", "CST6CDT"), + ("Canada/Atlantic", "Canada/Atlantic"), + ("Canada/Central", "Canada/Central"), + ("Canada/Eastern", "Canada/Eastern"), + ("Canada/Mountain", "Canada/Mountain"), + ("Canada/Newfoundland", "Canada/Newfoundland"), + ("Canada/Pacific", "Canada/Pacific"), + ("Canada/Saskatchewan", "Canada/Saskatchewan"), + ("Canada/Yukon", "Canada/Yukon"), + ("Chile/Continental", "Chile/Continental"), + ("Chile/EasterIsland", "Chile/EasterIsland"), + ("Cuba", "Cuba"), + ("EET", "EET"), + ("EST", "EST"), + ("EST5EDT", "EST5EDT"), + ("Egypt", "Egypt"), + ("Eire", "Eire"), + ("Etc/GMT", "Etc/GMT"), + ("Etc/GMT+0", "Etc/GMT+0"), + ("Etc/GMT+1", "Etc/GMT+1"), + ("Etc/GMT+10", "Etc/GMT+10"), + ("Etc/GMT+11", "Etc/GMT+11"), + ("Etc/GMT+12", "Etc/GMT+12"), + ("Etc/GMT+2", "Etc/GMT+2"), + ("Etc/GMT+3", "Etc/GMT+3"), + ("Etc/GMT+4", "Etc/GMT+4"), + ("Etc/GMT+5", "Etc/GMT+5"), + ("Etc/GMT+6", "Etc/GMT+6"), + ("Etc/GMT+7", "Etc/GMT+7"), + ("Etc/GMT+8", "Etc/GMT+8"), + ("Etc/GMT+9", "Etc/GMT+9"), + ("Etc/GMT-0", "Etc/GMT-0"), + ("Etc/GMT-1", "Etc/GMT-1"), + ("Etc/GMT-10", "Etc/GMT-10"), + ("Etc/GMT-11", "Etc/GMT-11"), + ("Etc/GMT-12", "Etc/GMT-12"), + ("Etc/GMT-13", "Etc/GMT-13"), + ("Etc/GMT-14", "Etc/GMT-14"), + ("Etc/GMT-2", "Etc/GMT-2"), + ("Etc/GMT-3", "Etc/GMT-3"), + ("Etc/GMT-4", "Etc/GMT-4"), + ("Etc/GMT-5", "Etc/GMT-5"), + ("Etc/GMT-6", "Etc/GMT-6"), + ("Etc/GMT-7", "Etc/GMT-7"), + ("Etc/GMT-8", "Etc/GMT-8"), + ("Etc/GMT-9", "Etc/GMT-9"), + ("Etc/GMT0", "Etc/GMT0"), + ("Etc/Greenwich", "Etc/Greenwich"), + ("Etc/UCT", "Etc/UCT"), + ("Etc/UTC", "Etc/UTC"), + ("Etc/Universal", "Etc/Universal"), + ("Etc/Zulu", "Etc/Zulu"), + ("Europe/Amsterdam", "Europe/Amsterdam"), + ("Europe/Andorra", "Europe/Andorra"), + ("Europe/Astrakhan", "Europe/Astrakhan"), + ("Europe/Athens", "Europe/Athens"), + ("Europe/Belfast", "Europe/Belfast"), + ("Europe/Belgrade", "Europe/Belgrade"), + ("Europe/Berlin", "Europe/Berlin"), + ("Europe/Bratislava", "Europe/Bratislava"), + ("Europe/Brussels", "Europe/Brussels"), + ("Europe/Bucharest", "Europe/Bucharest"), + ("Europe/Budapest", "Europe/Budapest"), + ("Europe/Busingen", "Europe/Busingen"), + ("Europe/Chisinau", "Europe/Chisinau"), + ("Europe/Copenhagen", "Europe/Copenhagen"), + ("Europe/Dublin", "Europe/Dublin"), + ("Europe/Gibraltar", "Europe/Gibraltar"), + ("Europe/Guernsey", "Europe/Guernsey"), + ("Europe/Helsinki", "Europe/Helsinki"), + ("Europe/Isle_of_Man", "Europe/Isle_of_Man"), + ("Europe/Istanbul", "Europe/Istanbul"), + ("Europe/Jersey", "Europe/Jersey"), + ("Europe/Kaliningrad", "Europe/Kaliningrad"), + ("Europe/Kiev", "Europe/Kiev"), + ("Europe/Kirov", "Europe/Kirov"), + ("Europe/Lisbon", "Europe/Lisbon"), + ("Europe/Ljubljana", "Europe/Ljubljana"), + ("Europe/London", "Europe/London"), + ("Europe/Luxembourg", "Europe/Luxembourg"), + ("Europe/Madrid", "Europe/Madrid"), + ("Europe/Malta", "Europe/Malta"), + ("Europe/Mariehamn", "Europe/Mariehamn"), + ("Europe/Minsk", "Europe/Minsk"), + ("Europe/Monaco", "Europe/Monaco"), + ("Europe/Moscow", "Europe/Moscow"), + ("Europe/Nicosia", "Europe/Nicosia"), + ("Europe/Oslo", "Europe/Oslo"), + ("Europe/Paris", "Europe/Paris"), + ("Europe/Podgorica", "Europe/Podgorica"), + ("Europe/Prague", "Europe/Prague"), + ("Europe/Riga", "Europe/Riga"), + ("Europe/Rome", "Europe/Rome"), + ("Europe/Samara", "Europe/Samara"), + ("Europe/San_Marino", "Europe/San_Marino"), + ("Europe/Sarajevo", "Europe/Sarajevo"), + ("Europe/Saratov", "Europe/Saratov"), + ("Europe/Simferopol", "Europe/Simferopol"), + ("Europe/Skopje", "Europe/Skopje"), + ("Europe/Sofia", "Europe/Sofia"), + ("Europe/Stockholm", "Europe/Stockholm"), + ("Europe/Tallinn", "Europe/Tallinn"), + ("Europe/Tirane", "Europe/Tirane"), + ("Europe/Tiraspol", "Europe/Tiraspol"), + ("Europe/Ulyanovsk", "Europe/Ulyanovsk"), + ("Europe/Uzhgorod", "Europe/Uzhgorod"), + ("Europe/Vaduz", "Europe/Vaduz"), + ("Europe/Vatican", "Europe/Vatican"), + ("Europe/Vienna", "Europe/Vienna"), + ("Europe/Vilnius", "Europe/Vilnius"), + ("Europe/Volgograd", "Europe/Volgograd"), + ("Europe/Warsaw", "Europe/Warsaw"), + ("Europe/Zagreb", "Europe/Zagreb"), + ("Europe/Zaporozhye", "Europe/Zaporozhye"), + ("Europe/Zurich", "Europe/Zurich"), + ("GB", "GB"), + ("GB-Eire", "GB-Eire"), + ("GMT", "GMT"), + ("GMT+0", "GMT+0"), + ("GMT-0", "GMT-0"), + ("GMT0", "GMT0"), + ("Greenwich", "Greenwich"), + ("HST", "HST"), + ("Hongkong", "Hongkong"), + ("Iceland", "Iceland"), + ("Indian/Antananarivo", "Indian/Antananarivo"), + ("Indian/Chagos", "Indian/Chagos"), + ("Indian/Christmas", "Indian/Christmas"), + ("Indian/Cocos", "Indian/Cocos"), + ("Indian/Comoro", "Indian/Comoro"), + ("Indian/Kerguelen", "Indian/Kerguelen"), + ("Indian/Mahe", "Indian/Mahe"), + ("Indian/Maldives", "Indian/Maldives"), + ("Indian/Mauritius", "Indian/Mauritius"), + ("Indian/Mayotte", "Indian/Mayotte"), + ("Indian/Reunion", "Indian/Reunion"), + ("Iran", "Iran"), + ("Israel", "Israel"), + ("Jamaica", "Jamaica"), + ("Japan", "Japan"), + ("Kwajalein", "Kwajalein"), + ("Libya", "Libya"), + ("MET", "MET"), + ("MST", "MST"), + ("MST7MDT", "MST7MDT"), + ("Mexico/BajaNorte", "Mexico/BajaNorte"), + ("Mexico/BajaSur", "Mexico/BajaSur"), + ("Mexico/General", "Mexico/General"), + ("NZ", "NZ"), + ("NZ-CHAT", "NZ-CHAT"), + ("Navajo", "Navajo"), + ("PRC", "PRC"), + ("PST8PDT", "PST8PDT"), + ("Pacific/Apia", "Pacific/Apia"), + ("Pacific/Auckland", "Pacific/Auckland"), + ("Pacific/Bougainville", "Pacific/Bougainville"), + ("Pacific/Chatham", "Pacific/Chatham"), + ("Pacific/Chuuk", "Pacific/Chuuk"), + ("Pacific/Easter", "Pacific/Easter"), + ("Pacific/Efate", "Pacific/Efate"), + ("Pacific/Enderbury", "Pacific/Enderbury"), + ("Pacific/Fakaofo", "Pacific/Fakaofo"), + ("Pacific/Fiji", "Pacific/Fiji"), + ("Pacific/Funafuti", "Pacific/Funafuti"), + ("Pacific/Galapagos", "Pacific/Galapagos"), + ("Pacific/Gambier", "Pacific/Gambier"), + ("Pacific/Guadalcanal", "Pacific/Guadalcanal"), + ("Pacific/Guam", "Pacific/Guam"), + ("Pacific/Honolulu", "Pacific/Honolulu"), + ("Pacific/Johnston", "Pacific/Johnston"), + ("Pacific/Kiritimati", "Pacific/Kiritimati"), + ("Pacific/Kosrae", "Pacific/Kosrae"), + ("Pacific/Kwajalein", "Pacific/Kwajalein"), + ("Pacific/Majuro", "Pacific/Majuro"), + ("Pacific/Marquesas", "Pacific/Marquesas"), + ("Pacific/Midway", "Pacific/Midway"), + ("Pacific/Nauru", "Pacific/Nauru"), + ("Pacific/Niue", "Pacific/Niue"), + ("Pacific/Norfolk", "Pacific/Norfolk"), + ("Pacific/Noumea", "Pacific/Noumea"), + ("Pacific/Pago_Pago", "Pacific/Pago_Pago"), + ("Pacific/Palau", "Pacific/Palau"), + ("Pacific/Pitcairn", "Pacific/Pitcairn"), + ("Pacific/Pohnpei", "Pacific/Pohnpei"), + ("Pacific/Ponape", "Pacific/Ponape"), + ("Pacific/Port_Moresby", "Pacific/Port_Moresby"), + ("Pacific/Rarotonga", "Pacific/Rarotonga"), + ("Pacific/Saipan", "Pacific/Saipan"), + ("Pacific/Samoa", "Pacific/Samoa"), + ("Pacific/Tahiti", "Pacific/Tahiti"), + ("Pacific/Tarawa", "Pacific/Tarawa"), + ("Pacific/Tongatapu", "Pacific/Tongatapu"), + ("Pacific/Truk", "Pacific/Truk"), + ("Pacific/Wake", "Pacific/Wake"), + ("Pacific/Wallis", "Pacific/Wallis"), + ("Pacific/Yap", "Pacific/Yap"), + ("Poland", "Poland"), + ("Portugal", "Portugal"), + ("ROC", "ROC"), + ("ROK", "ROK"), + ("Singapore", "Singapore"), + ("Turkey", "Turkey"), + ("UCT", "UCT"), + ("US/Alaska", "US/Alaska"), + ("US/Aleutian", "US/Aleutian"), + ("US/Arizona", "US/Arizona"), + ("US/Central", "US/Central"), + ("US/East-Indiana", "US/East-Indiana"), + ("US/Eastern", "US/Eastern"), + ("US/Hawaii", "US/Hawaii"), + ("US/Indiana-Starke", "US/Indiana-Starke"), + ("US/Michigan", "US/Michigan"), + ("US/Mountain", "US/Mountain"), + ("US/Pacific", "US/Pacific"), + ("US/Samoa", "US/Samoa"), + ("UTC", "UTC"), + ("Universal", "Universal"), + ("W-SU", "W-SU"), + ("WET", "WET"), + ("Zulu", "Zulu"), + ], + default="UTC", + max_length=100, + ), + ), + ("last_suceeded", models.DateTimeField(blank=True, null=True)), ("succeeded", models.BooleanField(default=False)), + ("error", models.CharField(blank=True, max_length=255, null=True)), ], + options={"abstract": False}, ) ] diff --git a/src/newsreader/news/collection/migrations/0002_auto_20190410_2028.py b/src/newsreader/news/collection/migrations/0002_auto_20190410_2028.py deleted file mode 100644 index ce45e0e..0000000 --- a/src/newsreader/news/collection/migrations/0002_auto_20190410_2028.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 2.2 on 2019-04-10 20:28 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [("collection", "0001_initial")] - - operations = [ - migrations.AlterField( - model_name="collectionrule", - name="last_suceeded", - field=models.DateTimeField(blank=True, null=True), - ) - ] diff --git a/src/newsreader/news/collection/migrations/0003_collectionrule_category.py b/src/newsreader/news/collection/migrations/0002_collectionrule_category.py similarity index 74% rename from src/newsreader/news/collection/migrations/0003_collectionrule_category.py rename to src/newsreader/news/collection/migrations/0002_collectionrule_category.py index b15f7f7..2cb1ee4 100644 --- a/src/newsreader/news/collection/migrations/0003_collectionrule_category.py +++ b/src/newsreader/news/collection/migrations/0002_collectionrule_category.py @@ -1,13 +1,14 @@ -# Generated by Django 2.2 on 2019-05-20 20:06 - -import django.db.models.deletion +# Generated by Django 2.2 on 2019-07-05 20:59 from django.db import migrations, models +import django.db.models.deletion class Migration(migrations.Migration): - dependencies = [("posts", "0002_auto_20190520_2206"), ("collection", "0002_auto_20190410_2028")] + initial = True + + dependencies = [("collection", "0001_initial"), ("core", "0001_initial")] operations = [ migrations.AddField( @@ -18,7 +19,7 @@ class Migration(migrations.Migration): help_text="Posts from this rule will be tagged with this category", null=True, on_delete=django.db.models.deletion.SET_NULL, - to="posts.Category", + to="core.Category", verbose_name="Category", ), ) diff --git a/src/newsreader/news/collection/migrations/0003_collectionrule_user.py b/src/newsreader/news/collection/migrations/0003_collectionrule_user.py new file mode 100644 index 0000000..a735ea3 --- /dev/null +++ b/src/newsreader/news/collection/migrations/0003_collectionrule_user.py @@ -0,0 +1,21 @@ +# Generated by Django 2.2 on 2019-07-07 17:08 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("collection", "0002_collectionrule_category"), + ] + + operations = [ + migrations.AddField( + model_name="collectionrule", + name="user", + field=models.ForeignKey(default=None, on_delete="Owner", to=settings.AUTH_USER_MODEL), + preserve_default=False, + ) + ] diff --git a/src/newsreader/news/collection/migrations/0004_collectionrule_timezone.py b/src/newsreader/news/collection/migrations/0004_collectionrule_timezone.py deleted file mode 100644 index 837e625..0000000 --- a/src/newsreader/news/collection/migrations/0004_collectionrule_timezone.py +++ /dev/null @@ -1,613 +0,0 @@ -# Generated by Django 2.2 on 2019-05-20 20:41 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [("collection", "0003_collectionrule_category")] - - operations = [ - migrations.AddField( - model_name="collectionrule", - name="timezone", - field=models.CharField( - choices=[ - ("Africa/Abidjan", "Africa/Abidjan"), - ("Africa/Accra", "Africa/Accra"), - ("Africa/Addis_Ababa", "Africa/Addis_Ababa"), - ("Africa/Algiers", "Africa/Algiers"), - ("Africa/Asmara", "Africa/Asmara"), - ("Africa/Asmera", "Africa/Asmera"), - ("Africa/Bamako", "Africa/Bamako"), - ("Africa/Bangui", "Africa/Bangui"), - ("Africa/Banjul", "Africa/Banjul"), - ("Africa/Bissau", "Africa/Bissau"), - ("Africa/Blantyre", "Africa/Blantyre"), - ("Africa/Brazzaville", "Africa/Brazzaville"), - ("Africa/Bujumbura", "Africa/Bujumbura"), - ("Africa/Cairo", "Africa/Cairo"), - ("Africa/Casablanca", "Africa/Casablanca"), - ("Africa/Ceuta", "Africa/Ceuta"), - ("Africa/Conakry", "Africa/Conakry"), - ("Africa/Dakar", "Africa/Dakar"), - ("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"), - ("Africa/Djibouti", "Africa/Djibouti"), - ("Africa/Douala", "Africa/Douala"), - ("Africa/El_Aaiun", "Africa/El_Aaiun"), - ("Africa/Freetown", "Africa/Freetown"), - ("Africa/Gaborone", "Africa/Gaborone"), - ("Africa/Harare", "Africa/Harare"), - ("Africa/Johannesburg", "Africa/Johannesburg"), - ("Africa/Juba", "Africa/Juba"), - ("Africa/Kampala", "Africa/Kampala"), - ("Africa/Khartoum", "Africa/Khartoum"), - ("Africa/Kigali", "Africa/Kigali"), - ("Africa/Kinshasa", "Africa/Kinshasa"), - ("Africa/Lagos", "Africa/Lagos"), - ("Africa/Libreville", "Africa/Libreville"), - ("Africa/Lome", "Africa/Lome"), - ("Africa/Luanda", "Africa/Luanda"), - ("Africa/Lubumbashi", "Africa/Lubumbashi"), - ("Africa/Lusaka", "Africa/Lusaka"), - ("Africa/Malabo", "Africa/Malabo"), - ("Africa/Maputo", "Africa/Maputo"), - ("Africa/Maseru", "Africa/Maseru"), - ("Africa/Mbabane", "Africa/Mbabane"), - ("Africa/Mogadishu", "Africa/Mogadishu"), - ("Africa/Monrovia", "Africa/Monrovia"), - ("Africa/Nairobi", "Africa/Nairobi"), - ("Africa/Ndjamena", "Africa/Ndjamena"), - ("Africa/Niamey", "Africa/Niamey"), - ("Africa/Nouakchott", "Africa/Nouakchott"), - ("Africa/Ouagadougou", "Africa/Ouagadougou"), - ("Africa/Porto-Novo", "Africa/Porto-Novo"), - ("Africa/Sao_Tome", "Africa/Sao_Tome"), - ("Africa/Timbuktu", "Africa/Timbuktu"), - ("Africa/Tripoli", "Africa/Tripoli"), - ("Africa/Tunis", "Africa/Tunis"), - ("Africa/Windhoek", "Africa/Windhoek"), - ("America/Adak", "America/Adak"), - ("America/Anchorage", "America/Anchorage"), - ("America/Anguilla", "America/Anguilla"), - ("America/Antigua", "America/Antigua"), - ("America/Araguaina", "America/Araguaina"), - ("America/Argentina/Buenos_Aires", "America/Argentina/Buenos_Aires"), - ("America/Argentina/Catamarca", "America/Argentina/Catamarca"), - ("America/Argentina/ComodRivadavia", "America/Argentina/ComodRivadavia"), - ("America/Argentina/Cordoba", "America/Argentina/Cordoba"), - ("America/Argentina/Jujuy", "America/Argentina/Jujuy"), - ("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"), - ("America/Argentina/Mendoza", "America/Argentina/Mendoza"), - ("America/Argentina/Rio_Gallegos", "America/Argentina/Rio_Gallegos"), - ("America/Argentina/Salta", "America/Argentina/Salta"), - ("America/Argentina/San_Juan", "America/Argentina/San_Juan"), - ("America/Argentina/San_Luis", "America/Argentina/San_Luis"), - ("America/Argentina/Tucuman", "America/Argentina/Tucuman"), - ("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"), - ("America/Aruba", "America/Aruba"), - ("America/Asuncion", "America/Asuncion"), - ("America/Atikokan", "America/Atikokan"), - ("America/Atka", "America/Atka"), - ("America/Bahia", "America/Bahia"), - ("America/Bahia_Banderas", "America/Bahia_Banderas"), - ("America/Barbados", "America/Barbados"), - ("America/Belem", "America/Belem"), - ("America/Belize", "America/Belize"), - ("America/Blanc-Sablon", "America/Blanc-Sablon"), - ("America/Boa_Vista", "America/Boa_Vista"), - ("America/Bogota", "America/Bogota"), - ("America/Boise", "America/Boise"), - ("America/Buenos_Aires", "America/Buenos_Aires"), - ("America/Cambridge_Bay", "America/Cambridge_Bay"), - ("America/Campo_Grande", "America/Campo_Grande"), - ("America/Cancun", "America/Cancun"), - ("America/Caracas", "America/Caracas"), - ("America/Catamarca", "America/Catamarca"), - ("America/Cayenne", "America/Cayenne"), - ("America/Cayman", "America/Cayman"), - ("America/Chicago", "America/Chicago"), - ("America/Chihuahua", "America/Chihuahua"), - ("America/Coral_Harbour", "America/Coral_Harbour"), - ("America/Cordoba", "America/Cordoba"), - ("America/Costa_Rica", "America/Costa_Rica"), - ("America/Creston", "America/Creston"), - ("America/Cuiaba", "America/Cuiaba"), - ("America/Curacao", "America/Curacao"), - ("America/Danmarkshavn", "America/Danmarkshavn"), - ("America/Dawson", "America/Dawson"), - ("America/Dawson_Creek", "America/Dawson_Creek"), - ("America/Denver", "America/Denver"), - ("America/Detroit", "America/Detroit"), - ("America/Dominica", "America/Dominica"), - ("America/Edmonton", "America/Edmonton"), - ("America/Eirunepe", "America/Eirunepe"), - ("America/El_Salvador", "America/El_Salvador"), - ("America/Ensenada", "America/Ensenada"), - ("America/Fort_Nelson", "America/Fort_Nelson"), - ("America/Fort_Wayne", "America/Fort_Wayne"), - ("America/Fortaleza", "America/Fortaleza"), - ("America/Glace_Bay", "America/Glace_Bay"), - ("America/Godthab", "America/Godthab"), - ("America/Goose_Bay", "America/Goose_Bay"), - ("America/Grand_Turk", "America/Grand_Turk"), - ("America/Grenada", "America/Grenada"), - ("America/Guadeloupe", "America/Guadeloupe"), - ("America/Guatemala", "America/Guatemala"), - ("America/Guayaquil", "America/Guayaquil"), - ("America/Guyana", "America/Guyana"), - ("America/Halifax", "America/Halifax"), - ("America/Havana", "America/Havana"), - ("America/Hermosillo", "America/Hermosillo"), - ("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"), - ("America/Indiana/Knox", "America/Indiana/Knox"), - ("America/Indiana/Marengo", "America/Indiana/Marengo"), - ("America/Indiana/Petersburg", "America/Indiana/Petersburg"), - ("America/Indiana/Tell_City", "America/Indiana/Tell_City"), - ("America/Indiana/Vevay", "America/Indiana/Vevay"), - ("America/Indiana/Vincennes", "America/Indiana/Vincennes"), - ("America/Indiana/Winamac", "America/Indiana/Winamac"), - ("America/Indianapolis", "America/Indianapolis"), - ("America/Inuvik", "America/Inuvik"), - ("America/Iqaluit", "America/Iqaluit"), - ("America/Jamaica", "America/Jamaica"), - ("America/Jujuy", "America/Jujuy"), - ("America/Juneau", "America/Juneau"), - ("America/Kentucky/Louisville", "America/Kentucky/Louisville"), - ("America/Kentucky/Monticello", "America/Kentucky/Monticello"), - ("America/Knox_IN", "America/Knox_IN"), - ("America/Kralendijk", "America/Kralendijk"), - ("America/La_Paz", "America/La_Paz"), - ("America/Lima", "America/Lima"), - ("America/Los_Angeles", "America/Los_Angeles"), - ("America/Louisville", "America/Louisville"), - ("America/Lower_Princes", "America/Lower_Princes"), - ("America/Maceio", "America/Maceio"), - ("America/Managua", "America/Managua"), - ("America/Manaus", "America/Manaus"), - ("America/Marigot", "America/Marigot"), - ("America/Martinique", "America/Martinique"), - ("America/Matamoros", "America/Matamoros"), - ("America/Mazatlan", "America/Mazatlan"), - ("America/Mendoza", "America/Mendoza"), - ("America/Menominee", "America/Menominee"), - ("America/Merida", "America/Merida"), - ("America/Metlakatla", "America/Metlakatla"), - ("America/Mexico_City", "America/Mexico_City"), - ("America/Miquelon", "America/Miquelon"), - ("America/Moncton", "America/Moncton"), - ("America/Monterrey", "America/Monterrey"), - ("America/Montevideo", "America/Montevideo"), - ("America/Montreal", "America/Montreal"), - ("America/Montserrat", "America/Montserrat"), - ("America/Nassau", "America/Nassau"), - ("America/New_York", "America/New_York"), - ("America/Nipigon", "America/Nipigon"), - ("America/Nome", "America/Nome"), - ("America/Noronha", "America/Noronha"), - ("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"), - ("America/North_Dakota/Center", "America/North_Dakota/Center"), - ("America/North_Dakota/New_Salem", "America/North_Dakota/New_Salem"), - ("America/Ojinaga", "America/Ojinaga"), - ("America/Panama", "America/Panama"), - ("America/Pangnirtung", "America/Pangnirtung"), - ("America/Paramaribo", "America/Paramaribo"), - ("America/Phoenix", "America/Phoenix"), - ("America/Port-au-Prince", "America/Port-au-Prince"), - ("America/Port_of_Spain", "America/Port_of_Spain"), - ("America/Porto_Acre", "America/Porto_Acre"), - ("America/Porto_Velho", "America/Porto_Velho"), - ("America/Puerto_Rico", "America/Puerto_Rico"), - ("America/Punta_Arenas", "America/Punta_Arenas"), - ("America/Rainy_River", "America/Rainy_River"), - ("America/Rankin_Inlet", "America/Rankin_Inlet"), - ("America/Recife", "America/Recife"), - ("America/Regina", "America/Regina"), - ("America/Resolute", "America/Resolute"), - ("America/Rio_Branco", "America/Rio_Branco"), - ("America/Rosario", "America/Rosario"), - ("America/Santa_Isabel", "America/Santa_Isabel"), - ("America/Santarem", "America/Santarem"), - ("America/Santiago", "America/Santiago"), - ("America/Santo_Domingo", "America/Santo_Domingo"), - ("America/Sao_Paulo", "America/Sao_Paulo"), - ("America/Scoresbysund", "America/Scoresbysund"), - ("America/Shiprock", "America/Shiprock"), - ("America/Sitka", "America/Sitka"), - ("America/St_Barthelemy", "America/St_Barthelemy"), - ("America/St_Johns", "America/St_Johns"), - ("America/St_Kitts", "America/St_Kitts"), - ("America/St_Lucia", "America/St_Lucia"), - ("America/St_Thomas", "America/St_Thomas"), - ("America/St_Vincent", "America/St_Vincent"), - ("America/Swift_Current", "America/Swift_Current"), - ("America/Tegucigalpa", "America/Tegucigalpa"), - ("America/Thule", "America/Thule"), - ("America/Thunder_Bay", "America/Thunder_Bay"), - ("America/Tijuana", "America/Tijuana"), - ("America/Toronto", "America/Toronto"), - ("America/Tortola", "America/Tortola"), - ("America/Vancouver", "America/Vancouver"), - ("America/Virgin", "America/Virgin"), - ("America/Whitehorse", "America/Whitehorse"), - ("America/Winnipeg", "America/Winnipeg"), - ("America/Yakutat", "America/Yakutat"), - ("America/Yellowknife", "America/Yellowknife"), - ("Antarctica/Casey", "Antarctica/Casey"), - ("Antarctica/Davis", "Antarctica/Davis"), - ("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"), - ("Antarctica/Macquarie", "Antarctica/Macquarie"), - ("Antarctica/Mawson", "Antarctica/Mawson"), - ("Antarctica/McMurdo", "Antarctica/McMurdo"), - ("Antarctica/Palmer", "Antarctica/Palmer"), - ("Antarctica/Rothera", "Antarctica/Rothera"), - ("Antarctica/South_Pole", "Antarctica/South_Pole"), - ("Antarctica/Syowa", "Antarctica/Syowa"), - ("Antarctica/Troll", "Antarctica/Troll"), - ("Antarctica/Vostok", "Antarctica/Vostok"), - ("Arctic/Longyearbyen", "Arctic/Longyearbyen"), - ("Asia/Aden", "Asia/Aden"), - ("Asia/Almaty", "Asia/Almaty"), - ("Asia/Amman", "Asia/Amman"), - ("Asia/Anadyr", "Asia/Anadyr"), - ("Asia/Aqtau", "Asia/Aqtau"), - ("Asia/Aqtobe", "Asia/Aqtobe"), - ("Asia/Ashgabat", "Asia/Ashgabat"), - ("Asia/Ashkhabad", "Asia/Ashkhabad"), - ("Asia/Atyrau", "Asia/Atyrau"), - ("Asia/Baghdad", "Asia/Baghdad"), - ("Asia/Bahrain", "Asia/Bahrain"), - ("Asia/Baku", "Asia/Baku"), - ("Asia/Bangkok", "Asia/Bangkok"), - ("Asia/Barnaul", "Asia/Barnaul"), - ("Asia/Beirut", "Asia/Beirut"), - ("Asia/Bishkek", "Asia/Bishkek"), - ("Asia/Brunei", "Asia/Brunei"), - ("Asia/Calcutta", "Asia/Calcutta"), - ("Asia/Chita", "Asia/Chita"), - ("Asia/Choibalsan", "Asia/Choibalsan"), - ("Asia/Chongqing", "Asia/Chongqing"), - ("Asia/Chungking", "Asia/Chungking"), - ("Asia/Colombo", "Asia/Colombo"), - ("Asia/Dacca", "Asia/Dacca"), - ("Asia/Damascus", "Asia/Damascus"), - ("Asia/Dhaka", "Asia/Dhaka"), - ("Asia/Dili", "Asia/Dili"), - ("Asia/Dubai", "Asia/Dubai"), - ("Asia/Dushanbe", "Asia/Dushanbe"), - ("Asia/Famagusta", "Asia/Famagusta"), - ("Asia/Gaza", "Asia/Gaza"), - ("Asia/Harbin", "Asia/Harbin"), - ("Asia/Hebron", "Asia/Hebron"), - ("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"), - ("Asia/Hong_Kong", "Asia/Hong_Kong"), - ("Asia/Hovd", "Asia/Hovd"), - ("Asia/Irkutsk", "Asia/Irkutsk"), - ("Asia/Istanbul", "Asia/Istanbul"), - ("Asia/Jakarta", "Asia/Jakarta"), - ("Asia/Jayapura", "Asia/Jayapura"), - ("Asia/Jerusalem", "Asia/Jerusalem"), - ("Asia/Kabul", "Asia/Kabul"), - ("Asia/Kamchatka", "Asia/Kamchatka"), - ("Asia/Karachi", "Asia/Karachi"), - ("Asia/Kashgar", "Asia/Kashgar"), - ("Asia/Kathmandu", "Asia/Kathmandu"), - ("Asia/Katmandu", "Asia/Katmandu"), - ("Asia/Khandyga", "Asia/Khandyga"), - ("Asia/Kolkata", "Asia/Kolkata"), - ("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"), - ("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"), - ("Asia/Kuching", "Asia/Kuching"), - ("Asia/Kuwait", "Asia/Kuwait"), - ("Asia/Macao", "Asia/Macao"), - ("Asia/Macau", "Asia/Macau"), - ("Asia/Magadan", "Asia/Magadan"), - ("Asia/Makassar", "Asia/Makassar"), - ("Asia/Manila", "Asia/Manila"), - ("Asia/Muscat", "Asia/Muscat"), - ("Asia/Nicosia", "Asia/Nicosia"), - ("Asia/Novokuznetsk", "Asia/Novokuznetsk"), - ("Asia/Novosibirsk", "Asia/Novosibirsk"), - ("Asia/Omsk", "Asia/Omsk"), - ("Asia/Oral", "Asia/Oral"), - ("Asia/Phnom_Penh", "Asia/Phnom_Penh"), - ("Asia/Pontianak", "Asia/Pontianak"), - ("Asia/Pyongyang", "Asia/Pyongyang"), - ("Asia/Qatar", "Asia/Qatar"), - ("Asia/Qostanay", "Asia/Qostanay"), - ("Asia/Qyzylorda", "Asia/Qyzylorda"), - ("Asia/Rangoon", "Asia/Rangoon"), - ("Asia/Riyadh", "Asia/Riyadh"), - ("Asia/Saigon", "Asia/Saigon"), - ("Asia/Sakhalin", "Asia/Sakhalin"), - ("Asia/Samarkand", "Asia/Samarkand"), - ("Asia/Seoul", "Asia/Seoul"), - ("Asia/Shanghai", "Asia/Shanghai"), - ("Asia/Singapore", "Asia/Singapore"), - ("Asia/Srednekolymsk", "Asia/Srednekolymsk"), - ("Asia/Taipei", "Asia/Taipei"), - ("Asia/Tashkent", "Asia/Tashkent"), - ("Asia/Tbilisi", "Asia/Tbilisi"), - ("Asia/Tehran", "Asia/Tehran"), - ("Asia/Tel_Aviv", "Asia/Tel_Aviv"), - ("Asia/Thimbu", "Asia/Thimbu"), - ("Asia/Thimphu", "Asia/Thimphu"), - ("Asia/Tokyo", "Asia/Tokyo"), - ("Asia/Tomsk", "Asia/Tomsk"), - ("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"), - ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"), - ("Asia/Ulan_Bator", "Asia/Ulan_Bator"), - ("Asia/Urumqi", "Asia/Urumqi"), - ("Asia/Ust-Nera", "Asia/Ust-Nera"), - ("Asia/Vientiane", "Asia/Vientiane"), - ("Asia/Vladivostok", "Asia/Vladivostok"), - ("Asia/Yakutsk", "Asia/Yakutsk"), - ("Asia/Yangon", "Asia/Yangon"), - ("Asia/Yekaterinburg", "Asia/Yekaterinburg"), - ("Asia/Yerevan", "Asia/Yerevan"), - ("Atlantic/Azores", "Atlantic/Azores"), - ("Atlantic/Bermuda", "Atlantic/Bermuda"), - ("Atlantic/Canary", "Atlantic/Canary"), - ("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"), - ("Atlantic/Faeroe", "Atlantic/Faeroe"), - ("Atlantic/Faroe", "Atlantic/Faroe"), - ("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"), - ("Atlantic/Madeira", "Atlantic/Madeira"), - ("Atlantic/Reykjavik", "Atlantic/Reykjavik"), - ("Atlantic/South_Georgia", "Atlantic/South_Georgia"), - ("Atlantic/St_Helena", "Atlantic/St_Helena"), - ("Atlantic/Stanley", "Atlantic/Stanley"), - ("Australia/ACT", "Australia/ACT"), - ("Australia/Adelaide", "Australia/Adelaide"), - ("Australia/Brisbane", "Australia/Brisbane"), - ("Australia/Broken_Hill", "Australia/Broken_Hill"), - ("Australia/Canberra", "Australia/Canberra"), - ("Australia/Currie", "Australia/Currie"), - ("Australia/Darwin", "Australia/Darwin"), - ("Australia/Eucla", "Australia/Eucla"), - ("Australia/Hobart", "Australia/Hobart"), - ("Australia/LHI", "Australia/LHI"), - ("Australia/Lindeman", "Australia/Lindeman"), - ("Australia/Lord_Howe", "Australia/Lord_Howe"), - ("Australia/Melbourne", "Australia/Melbourne"), - ("Australia/NSW", "Australia/NSW"), - ("Australia/North", "Australia/North"), - ("Australia/Perth", "Australia/Perth"), - ("Australia/Queensland", "Australia/Queensland"), - ("Australia/South", "Australia/South"), - ("Australia/Sydney", "Australia/Sydney"), - ("Australia/Tasmania", "Australia/Tasmania"), - ("Australia/Victoria", "Australia/Victoria"), - ("Australia/West", "Australia/West"), - ("Australia/Yancowinna", "Australia/Yancowinna"), - ("Brazil/Acre", "Brazil/Acre"), - ("Brazil/DeNoronha", "Brazil/DeNoronha"), - ("Brazil/East", "Brazil/East"), - ("Brazil/West", "Brazil/West"), - ("CET", "CET"), - ("CST6CDT", "CST6CDT"), - ("Canada/Atlantic", "Canada/Atlantic"), - ("Canada/Central", "Canada/Central"), - ("Canada/Eastern", "Canada/Eastern"), - ("Canada/Mountain", "Canada/Mountain"), - ("Canada/Newfoundland", "Canada/Newfoundland"), - ("Canada/Pacific", "Canada/Pacific"), - ("Canada/Saskatchewan", "Canada/Saskatchewan"), - ("Canada/Yukon", "Canada/Yukon"), - ("Chile/Continental", "Chile/Continental"), - ("Chile/EasterIsland", "Chile/EasterIsland"), - ("Cuba", "Cuba"), - ("EET", "EET"), - ("EST", "EST"), - ("EST5EDT", "EST5EDT"), - ("Egypt", "Egypt"), - ("Eire", "Eire"), - ("Etc/GMT", "Etc/GMT"), - ("Etc/GMT+0", "Etc/GMT+0"), - ("Etc/GMT+1", "Etc/GMT+1"), - ("Etc/GMT+10", "Etc/GMT+10"), - ("Etc/GMT+11", "Etc/GMT+11"), - ("Etc/GMT+12", "Etc/GMT+12"), - ("Etc/GMT+2", "Etc/GMT+2"), - ("Etc/GMT+3", "Etc/GMT+3"), - ("Etc/GMT+4", "Etc/GMT+4"), - ("Etc/GMT+5", "Etc/GMT+5"), - ("Etc/GMT+6", "Etc/GMT+6"), - ("Etc/GMT+7", "Etc/GMT+7"), - ("Etc/GMT+8", "Etc/GMT+8"), - ("Etc/GMT+9", "Etc/GMT+9"), - ("Etc/GMT-0", "Etc/GMT-0"), - ("Etc/GMT-1", "Etc/GMT-1"), - ("Etc/GMT-10", "Etc/GMT-10"), - ("Etc/GMT-11", "Etc/GMT-11"), - ("Etc/GMT-12", "Etc/GMT-12"), - ("Etc/GMT-13", "Etc/GMT-13"), - ("Etc/GMT-14", "Etc/GMT-14"), - ("Etc/GMT-2", "Etc/GMT-2"), - ("Etc/GMT-3", "Etc/GMT-3"), - ("Etc/GMT-4", "Etc/GMT-4"), - ("Etc/GMT-5", "Etc/GMT-5"), - ("Etc/GMT-6", "Etc/GMT-6"), - ("Etc/GMT-7", "Etc/GMT-7"), - ("Etc/GMT-8", "Etc/GMT-8"), - ("Etc/GMT-9", "Etc/GMT-9"), - ("Etc/GMT0", "Etc/GMT0"), - ("Etc/Greenwich", "Etc/Greenwich"), - ("Etc/UCT", "Etc/UCT"), - ("Etc/UTC", "Etc/UTC"), - ("Etc/Universal", "Etc/Universal"), - ("Etc/Zulu", "Etc/Zulu"), - ("Europe/Amsterdam", "Europe/Amsterdam"), - ("Europe/Andorra", "Europe/Andorra"), - ("Europe/Astrakhan", "Europe/Astrakhan"), - ("Europe/Athens", "Europe/Athens"), - ("Europe/Belfast", "Europe/Belfast"), - ("Europe/Belgrade", "Europe/Belgrade"), - ("Europe/Berlin", "Europe/Berlin"), - ("Europe/Bratislava", "Europe/Bratislava"), - ("Europe/Brussels", "Europe/Brussels"), - ("Europe/Bucharest", "Europe/Bucharest"), - ("Europe/Budapest", "Europe/Budapest"), - ("Europe/Busingen", "Europe/Busingen"), - ("Europe/Chisinau", "Europe/Chisinau"), - ("Europe/Copenhagen", "Europe/Copenhagen"), - ("Europe/Dublin", "Europe/Dublin"), - ("Europe/Gibraltar", "Europe/Gibraltar"), - ("Europe/Guernsey", "Europe/Guernsey"), - ("Europe/Helsinki", "Europe/Helsinki"), - ("Europe/Isle_of_Man", "Europe/Isle_of_Man"), - ("Europe/Istanbul", "Europe/Istanbul"), - ("Europe/Jersey", "Europe/Jersey"), - ("Europe/Kaliningrad", "Europe/Kaliningrad"), - ("Europe/Kiev", "Europe/Kiev"), - ("Europe/Kirov", "Europe/Kirov"), - ("Europe/Lisbon", "Europe/Lisbon"), - ("Europe/Ljubljana", "Europe/Ljubljana"), - ("Europe/London", "Europe/London"), - ("Europe/Luxembourg", "Europe/Luxembourg"), - ("Europe/Madrid", "Europe/Madrid"), - ("Europe/Malta", "Europe/Malta"), - ("Europe/Mariehamn", "Europe/Mariehamn"), - ("Europe/Minsk", "Europe/Minsk"), - ("Europe/Monaco", "Europe/Monaco"), - ("Europe/Moscow", "Europe/Moscow"), - ("Europe/Nicosia", "Europe/Nicosia"), - ("Europe/Oslo", "Europe/Oslo"), - ("Europe/Paris", "Europe/Paris"), - ("Europe/Podgorica", "Europe/Podgorica"), - ("Europe/Prague", "Europe/Prague"), - ("Europe/Riga", "Europe/Riga"), - ("Europe/Rome", "Europe/Rome"), - ("Europe/Samara", "Europe/Samara"), - ("Europe/San_Marino", "Europe/San_Marino"), - ("Europe/Sarajevo", "Europe/Sarajevo"), - ("Europe/Saratov", "Europe/Saratov"), - ("Europe/Simferopol", "Europe/Simferopol"), - ("Europe/Skopje", "Europe/Skopje"), - ("Europe/Sofia", "Europe/Sofia"), - ("Europe/Stockholm", "Europe/Stockholm"), - ("Europe/Tallinn", "Europe/Tallinn"), - ("Europe/Tirane", "Europe/Tirane"), - ("Europe/Tiraspol", "Europe/Tiraspol"), - ("Europe/Ulyanovsk", "Europe/Ulyanovsk"), - ("Europe/Uzhgorod", "Europe/Uzhgorod"), - ("Europe/Vaduz", "Europe/Vaduz"), - ("Europe/Vatican", "Europe/Vatican"), - ("Europe/Vienna", "Europe/Vienna"), - ("Europe/Vilnius", "Europe/Vilnius"), - ("Europe/Volgograd", "Europe/Volgograd"), - ("Europe/Warsaw", "Europe/Warsaw"), - ("Europe/Zagreb", "Europe/Zagreb"), - ("Europe/Zaporozhye", "Europe/Zaporozhye"), - ("Europe/Zurich", "Europe/Zurich"), - ("GB", "GB"), - ("GB-Eire", "GB-Eire"), - ("GMT", "GMT"), - ("GMT+0", "GMT+0"), - ("GMT-0", "GMT-0"), - ("GMT0", "GMT0"), - ("Greenwich", "Greenwich"), - ("HST", "HST"), - ("Hongkong", "Hongkong"), - ("Iceland", "Iceland"), - ("Indian/Antananarivo", "Indian/Antananarivo"), - ("Indian/Chagos", "Indian/Chagos"), - ("Indian/Christmas", "Indian/Christmas"), - ("Indian/Cocos", "Indian/Cocos"), - ("Indian/Comoro", "Indian/Comoro"), - ("Indian/Kerguelen", "Indian/Kerguelen"), - ("Indian/Mahe", "Indian/Mahe"), - ("Indian/Maldives", "Indian/Maldives"), - ("Indian/Mauritius", "Indian/Mauritius"), - ("Indian/Mayotte", "Indian/Mayotte"), - ("Indian/Reunion", "Indian/Reunion"), - ("Iran", "Iran"), - ("Israel", "Israel"), - ("Jamaica", "Jamaica"), - ("Japan", "Japan"), - ("Kwajalein", "Kwajalein"), - ("Libya", "Libya"), - ("MET", "MET"), - ("MST", "MST"), - ("MST7MDT", "MST7MDT"), - ("Mexico/BajaNorte", "Mexico/BajaNorte"), - ("Mexico/BajaSur", "Mexico/BajaSur"), - ("Mexico/General", "Mexico/General"), - ("NZ", "NZ"), - ("NZ-CHAT", "NZ-CHAT"), - ("Navajo", "Navajo"), - ("PRC", "PRC"), - ("PST8PDT", "PST8PDT"), - ("Pacific/Apia", "Pacific/Apia"), - ("Pacific/Auckland", "Pacific/Auckland"), - ("Pacific/Bougainville", "Pacific/Bougainville"), - ("Pacific/Chatham", "Pacific/Chatham"), - ("Pacific/Chuuk", "Pacific/Chuuk"), - ("Pacific/Easter", "Pacific/Easter"), - ("Pacific/Efate", "Pacific/Efate"), - ("Pacific/Enderbury", "Pacific/Enderbury"), - ("Pacific/Fakaofo", "Pacific/Fakaofo"), - ("Pacific/Fiji", "Pacific/Fiji"), - ("Pacific/Funafuti", "Pacific/Funafuti"), - ("Pacific/Galapagos", "Pacific/Galapagos"), - ("Pacific/Gambier", "Pacific/Gambier"), - ("Pacific/Guadalcanal", "Pacific/Guadalcanal"), - ("Pacific/Guam", "Pacific/Guam"), - ("Pacific/Honolulu", "Pacific/Honolulu"), - ("Pacific/Johnston", "Pacific/Johnston"), - ("Pacific/Kiritimati", "Pacific/Kiritimati"), - ("Pacific/Kosrae", "Pacific/Kosrae"), - ("Pacific/Kwajalein", "Pacific/Kwajalein"), - ("Pacific/Majuro", "Pacific/Majuro"), - ("Pacific/Marquesas", "Pacific/Marquesas"), - ("Pacific/Midway", "Pacific/Midway"), - ("Pacific/Nauru", "Pacific/Nauru"), - ("Pacific/Niue", "Pacific/Niue"), - ("Pacific/Norfolk", "Pacific/Norfolk"), - ("Pacific/Noumea", "Pacific/Noumea"), - ("Pacific/Pago_Pago", "Pacific/Pago_Pago"), - ("Pacific/Palau", "Pacific/Palau"), - ("Pacific/Pitcairn", "Pacific/Pitcairn"), - ("Pacific/Pohnpei", "Pacific/Pohnpei"), - ("Pacific/Ponape", "Pacific/Ponape"), - ("Pacific/Port_Moresby", "Pacific/Port_Moresby"), - ("Pacific/Rarotonga", "Pacific/Rarotonga"), - ("Pacific/Saipan", "Pacific/Saipan"), - ("Pacific/Samoa", "Pacific/Samoa"), - ("Pacific/Tahiti", "Pacific/Tahiti"), - ("Pacific/Tarawa", "Pacific/Tarawa"), - ("Pacific/Tongatapu", "Pacific/Tongatapu"), - ("Pacific/Truk", "Pacific/Truk"), - ("Pacific/Wake", "Pacific/Wake"), - ("Pacific/Wallis", "Pacific/Wallis"), - ("Pacific/Yap", "Pacific/Yap"), - ("Poland", "Poland"), - ("Portugal", "Portugal"), - ("ROC", "ROC"), - ("ROK", "ROK"), - ("Singapore", "Singapore"), - ("Turkey", "Turkey"), - ("UCT", "UCT"), - ("US/Alaska", "US/Alaska"), - ("US/Aleutian", "US/Aleutian"), - ("US/Arizona", "US/Arizona"), - ("US/Central", "US/Central"), - ("US/East-Indiana", "US/East-Indiana"), - ("US/Eastern", "US/Eastern"), - ("US/Hawaii", "US/Hawaii"), - ("US/Indiana-Starke", "US/Indiana-Starke"), - ("US/Michigan", "US/Michigan"), - ("US/Mountain", "US/Mountain"), - ("US/Pacific", "US/Pacific"), - ("US/Samoa", "US/Samoa"), - ("UTC", "UTC"), - ("Universal", "Universal"), - ("W-SU", "W-SU"), - ("WET", "WET"), - ("Zulu", "Zulu"), - ], - default="UTC", - max_length=100, - ), - ) - ] diff --git a/src/newsreader/news/collection/migrations/0005_auto_20190521_1941.py b/src/newsreader/news/collection/migrations/0005_auto_20190521_1941.py deleted file mode 100644 index 8e3a53f..0000000 --- a/src/newsreader/news/collection/migrations/0005_auto_20190521_1941.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 2.2 on 2019-05-21 19:41 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [("collection", "0004_collectionrule_timezone")] - - operations = [ - migrations.AddField( - model_name="collectionrule", - name="favicon", - field=models.ImageField(blank=True, null=True, upload_to=""), - ), - migrations.AddField( - model_name="collectionrule", - name="source", - field=models.CharField(default="source", max_length=100), - preserve_default=False, - ), - ] diff --git a/src/newsreader/news/collection/migrations/0006_collectionrule_error.py b/src/newsreader/news/collection/migrations/0006_collectionrule_error.py deleted file mode 100644 index b70b638..0000000 --- a/src/newsreader/news/collection/migrations/0006_collectionrule_error.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 2.2 on 2019-06-08 14:13 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [("collection", "0005_auto_20190521_1941")] - - operations = [ - migrations.AddField( - model_name="collectionrule", - name="error", - field=models.CharField(blank=True, max_length=255, null=True), - ) - ] diff --git a/src/newsreader/news/collection/migrations/0007_auto_20190623_1837.py b/src/newsreader/news/collection/migrations/0007_auto_20190623_1837.py deleted file mode 100644 index cc27d6e..0000000 --- a/src/newsreader/news/collection/migrations/0007_auto_20190623_1837.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 2.2 on 2019-06-23 18:37 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [("collection", "0006_collectionrule_error")] - - operations = [ - migrations.AlterField( - model_name="collectionrule", - name="favicon", - field=models.ImageField(default="favicons/default-favicon.ico", upload_to="favicons/"), - ) - ] diff --git a/src/newsreader/news/collection/migrations/0008_auto_20190623_1847.py b/src/newsreader/news/collection/migrations/0008_auto_20190623_1847.py deleted file mode 100644 index 3c8ae66..0000000 --- a/src/newsreader/news/collection/migrations/0008_auto_20190623_1847.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 2.2 on 2019-06-23 18:47 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [("collection", "0007_auto_20190623_1837")] - - operations = [ - migrations.AlterField( - model_name="collectionrule", - name="favicon", - field=models.URLField(blank=True, null=True), - ) - ] diff --git a/src/newsreader/news/collection/migrations/0009_collectionrule_website_url.py b/src/newsreader/news/collection/migrations/0009_collectionrule_website_url.py deleted file mode 100644 index e5273b3..0000000 --- a/src/newsreader/news/collection/migrations/0009_collectionrule_website_url.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 2.2 on 2019-06-27 21:27 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [("collection", "0008_auto_20190623_1847")] - - operations = [ - migrations.AddField( - model_name="collectionrule", - name="website_url", - field=models.URLField(blank=True, editable=False, null=True), - ) - ] diff --git a/src/newsreader/news/collection/migrations/0010_auto_20190628_2142.py b/src/newsreader/news/collection/migrations/0010_auto_20190628_2142.py deleted file mode 100644 index 3726158..0000000 --- a/src/newsreader/news/collection/migrations/0010_auto_20190628_2142.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.2 on 2019-06-28 21:42 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [("collection", "0009_collectionrule_website_url")] - - operations = [ - migrations.AlterField( - model_name="collectionrule", name="url", field=models.URLField(max_length=1024) - ), - migrations.AlterField( - model_name="collectionrule", - name="website_url", - field=models.URLField(blank=True, editable=False, max_length=1024, null=True), - ), - ] diff --git a/src/newsreader/news/collection/models.py b/src/newsreader/news/collection/models.py index 03768ac..d65176a 100644 --- a/src/newsreader/news/collection/models.py +++ b/src/newsreader/news/collection/models.py @@ -1,13 +1,13 @@ -from django.conf import settings from django.db import models from django.utils.translation import gettext as _ import pytz +from newsreader.core.models import TimeStampedModel -class CollectionRule(models.Model): + +class CollectionRule(TimeStampedModel): name = models.CharField(max_length=100) - source = models.CharField(max_length=100) url = models.URLField(max_length=1024) website_url = models.URLField(max_length=1024, editable=False, blank=True, null=True) @@ -20,7 +20,7 @@ class CollectionRule(models.Model): ) category = models.ForeignKey( - "posts.Category", + "core.Category", blank=True, null=True, verbose_name=_("Category"), @@ -30,8 +30,9 @@ class CollectionRule(models.Model): last_suceeded = models.DateTimeField(blank=True, null=True) succeeded = models.BooleanField(default=False) - error = models.CharField(max_length=255, blank=True, null=True) + user = models.ForeignKey("auth.User", _("Owner")) + def __str__(self): return self.name diff --git a/src/newsreader/news/collection/response_handler.py b/src/newsreader/news/collection/response_handler.py index e412475..d9598c4 100644 --- a/src/newsreader/news/collection/response_handler.py +++ b/src/newsreader/news/collection/response_handler.py @@ -1,6 +1,5 @@ from typing import ContextManager -from requests import Response from requests.exceptions import ConnectionError as RequestConnectionError from requests.exceptions import ( HTTPError, diff --git a/src/newsreader/news/collection/serializers.py b/src/newsreader/news/collection/serializers.py new file mode 100644 index 0000000..cf6f9ea --- /dev/null +++ b/src/newsreader/news/collection/serializers.py @@ -0,0 +1,21 @@ +from rest_framework import serializers + +from newsreader.news import core +from newsreader.news.collection.models import CollectionRule + + +class CollectionRuleSerializer(serializers.HyperlinkedModelSerializer): + posts = serializers.SerializerMethodField() + user = serializers.HiddenField(default=serializers.CurrentUserDefault()) + + def get_posts(self, instance): + request = self.context.get("request") + posts = instance.post_set.order_by("-publication_date") + + serializer = core.serializers.PostSerializer(posts, context={"request": request}, many=True) + return serializer.data + + class Meta: + model = CollectionRule + fields = ("id", "name", "url", "favicon", "category", "posts", "user") + extra_kwargs = {"category": {"view_name": "api:categories-detail"}} diff --git a/src/newsreader/news/collection/tests/__init__.py b/src/newsreader/news/collection/tests/__init__.py index ea6a7c0..e69de29 100644 --- a/src/newsreader/news/collection/tests/__init__.py +++ b/src/newsreader/news/collection/tests/__init__.py @@ -1,4 +0,0 @@ -from .favicon import * -from .feed import * -from .tests import * -from .utils import * diff --git a/src/newsreader/news/collection/tests/endpoints/__init__.py b/src/newsreader/news/collection/tests/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/endpoints/rules/__init__.py b/src/newsreader/news/collection/tests/endpoints/rules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/endpoints/rules/detail/__init__.py b/src/newsreader/news/collection/tests/endpoints/rules/detail/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/endpoints/rules/detail/tests.py b/src/newsreader/news/collection/tests/endpoints/rules/detail/tests.py new file mode 100644 index 0000000..8782533 --- /dev/null +++ b/src/newsreader/news/collection/tests/endpoints/rules/detail/tests.py @@ -0,0 +1,186 @@ +import json + +from urllib.parse import urljoin + +from django.test import Client, TestCase +from django.urls import reverse + +from newsreader.auth.tests.factories import UserFactory +from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.core.tests.factories import CategoryFactory + + +class CollectionRuleDetailViewTestCase(TestCase): + def setUp(self): + self.maxDiff = None + + self.user = UserFactory(is_staff=True, password="test") + self.client = Client() + + def test_simple(self): + rule = CollectionRuleFactory(user=self.user) + + self.client.force_login(self.user) + response = self.client.get(reverse("api:rules-detail", args=[rule.pk])) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["id"], rule.pk) + + self.assertTrue("name" in data) + self.assertTrue("url" in data) + self.assertTrue("favicon" in data) + self.assertTrue("category" in data) + self.assertTrue("posts" in data) + + def test_not_known(self): + self.client.force_login(self.user) + response = self.client.get(reverse("api:rules-detail", args=[100])) + data = response.json() + + self.assertEquals(response.status_code, 404) + self.assertEquals(data["detail"], "Not found.") + + def test_post(self): + rule = CollectionRuleFactory(user=self.user) + + self.client.force_login(self.user) + response = self.client.post(reverse("api:rules-detail", args=[rule.pk])) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "POST" not allowed.') + + def test_patch(self): + rule = CollectionRuleFactory(name="BBC", user=self.user) + + self.client.force_login(self.user) + response = self.client.patch( + reverse("api:rules-detail", args=[rule.pk]), + data=json.dumps({"name": "The guardian"}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["name"], "The guardian") + + def test_category_change_with_absolute_url(self): + old_category = CategoryFactory(user=self.user) + new_category = CategoryFactory(user=self.user) + + base_url = "http://testserver" + relative_url = reverse("api:categories-detail", args=[new_category.pk]) + + absolute_url = urljoin(base_url, relative_url) + + rule = CollectionRuleFactory(name="BBC", category=old_category, user=self.user) + + self.client.force_login(self.user) + response = self.client.patch( + reverse("api:rules-detail", args=[rule.pk]), + data=json.dumps({"category": absolute_url}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["category"], absolute_url) + + def test_category_change_with_relative_url(self): + old_category = CategoryFactory(user=self.user) + new_category = CategoryFactory(user=self.user) + + base_url = "http://testserver" + relative_url = reverse("api:categories-detail", args=[new_category.pk]) + + absolute_url = urljoin(base_url, relative_url) + + rule = CollectionRuleFactory(name="BBC", category=old_category, user=self.user) + + self.client.force_login(self.user) + response = self.client.patch( + reverse("api:rules-detail", args=[rule.pk]), + data=json.dumps({"category": relative_url}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["category"], absolute_url) + + def test_identifier_cannot_be_changed(self): + rule = CollectionRuleFactory(user=self.user) + + self.client.force_login(self.user) + response = self.client.patch( + reverse("api:rules-detail", args=[rule.pk]), + data=json.dumps({"id": 44}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["id"], rule.pk) + + def test_category_change(self): + rule = CollectionRuleFactory(user=self.user) + category = CategoryFactory(user=self.user) + + self.client.force_login(self.user) + response = self.client.patch( + reverse("api:rules-detail", args=[rule.pk]), + data=json.dumps({"category": reverse("api:categories-detail", args=[category.pk])}), + content_type="application/json", + ) + data = response.json() + url = data["category"] + + self.assertEquals(response.status_code, 200) + self.assertTrue(url.endswith(reverse("api:categories-detail", args=[category.pk]))) + + def test_put(self): + rule = CollectionRuleFactory(name="BBC", user=self.user) + + self.client.force_login(self.user) + response = self.client.put( + reverse("api:rules-detail", args=[rule.pk]), + data=json.dumps({"name": "BBC", "url": "https://www.bbc.co.uk"}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["name"], "BBC") + + def test_delete(self): + rule = CollectionRuleFactory(user=self.user) + + self.client.force_login(self.user) + response = self.client.delete(reverse("api:rules-detail", args=[rule.pk])) + + self.assertEquals(response.status_code, 204) + + def test_rule_with_unauthenticated_user(self): + rule = CollectionRuleFactory(name="BBC", user=self.user) + + response = self.client.patch( + reverse("api:rules-detail", args=[rule.pk]), + data=json.dumps({"name": "The guardian"}), + content_type="application/json", + ) + + self.assertEquals(response.status_code, 403) + + def test_rule_with_unauthorized_user(self): + other_user = UserFactory() + rule = CollectionRuleFactory(name="BBC", user=other_user) + + self.client.force_login(self.user) + response = self.client.patch( + reverse("api:rules-detail", args=[rule.pk]), + data=json.dumps({"name": "The guardian"}), + content_type="application/json", + ) + + self.assertEquals(response.status_code, 403) diff --git a/src/newsreader/news/collection/tests/endpoints/rules/list/__init__.py b/src/newsreader/news/collection/tests/endpoints/rules/list/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/endpoints/rules/list/tests.py b/src/newsreader/news/collection/tests/endpoints/rules/list/tests.py new file mode 100644 index 0000000..f5a0cea --- /dev/null +++ b/src/newsreader/news/collection/tests/endpoints/rules/list/tests.py @@ -0,0 +1,206 @@ +import json + +from datetime import date, datetime, time + +from django.test import Client, TestCase +from django.urls import reverse + +import pytz + +from newsreader.auth.tests.factories import UserFactory +from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.core.tests.factories import CategoryFactory, PostFactory + + +class CollectionRuleListViewTestCase(TestCase): + def setUp(self): + self.maxDiff = None + + self.user = UserFactory(is_staff=True, password="test") + self.client = Client() + + def test_simple(self): + CollectionRuleFactory.create_batch(size=3, user=self.user) + + self.client.force_login(self.user) + response = self.client.get(reverse("api:rules-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertTrue("results" in data) + self.assertTrue("count" in data) + self.assertEquals(data["count"], 3) + + def test_ordering(self): + rules = [ + CollectionRuleFactory( + created=datetime.combine( + date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc + ), + user=self.user, + ), + CollectionRuleFactory( + created=datetime.combine( + date(2019, 7, 20), time(hour=18, minute=7, second=37), pytz.utc + ), + user=self.user, + ), + CollectionRuleFactory( + created=datetime.combine( + date(2019, 7, 20), time(hour=16, minute=7, second=37), pytz.utc + ), + user=self.user, + ), + ] + + self.client.force_login(self.user) + response = self.client.get(reverse("api:rules-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertTrue("results" in data) + self.assertTrue("count" in data) + self.assertEquals(data["count"], 3) + + self.assertEquals(data["results"][0]["id"], rules[1].pk) + self.assertEquals(data["results"][1]["id"], rules[2].pk) + self.assertEquals(data["results"][2]["id"], rules[0].pk) + + def test_pagination_count(self): + CollectionRuleFactory.create_batch(size=80, user=self.user) + + self.client.force_login(self.user) + response = self.client.get(reverse("api:rules-list"), {"count": 30}) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 80) + self.assertEquals(len(data["results"]), 30) + + def test_empty(self): + self.client.force_login(self.user) + response = self.client.get(reverse("api:rules-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertTrue("results" in data) + self.assertTrue("count" in data) + + self.assertEquals(data["count"], 0) + self.assertEquals(len(data["results"]), 0) + + def test_post(self): + category = CategoryFactory(user=self.user) + + data = { + "name": "BBC", + "url": "https://www.bbc.co.uk", + "category": reverse("api:categories-detail", args=[category.pk]), + } + + self.client.force_login(self.user) + response = self.client.post( + reverse("api:rules-list"), data=json.dumps(data), content_type="application/json" + ) + data = response.json() + category_url = data["category"] + + self.assertEquals(response.status_code, 201) + + self.assertEquals(data["name"], "BBC") + self.assertEquals(data["url"], "https://www.bbc.co.uk") + + self.assertTrue(category_url.endswith(reverse("api:categories-detail", args=[category.pk]))) + + def test_patch(self): + self.client.force_login(self.user) + response = self.client.patch(reverse("api:rules-list")) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "PATCH" not allowed.') + + def test_put(self): + self.client.force_login(self.user) + response = self.client.put(reverse("api:rules-list")) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "PUT" not allowed.') + + def test_delete(self): + self.client.force_login(self.user) + response = self.client.delete(reverse("api:rules-list")) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "DELETE" not allowed.') + + def test_rules_with_posts(self): + rules = { + rule: PostFactory.create_batch(size=5, rule=rule) + for rule in CollectionRuleFactory.create_batch(size=5, user=self.user) + } + + self.client.force_login(self.user) + response = self.client.get(reverse("api:rules-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertTrue("results" in data) + self.assertTrue("count" in data) + self.assertEquals(data["count"], 5) + + self.assertEquals(len(data["results"]), 5) + + self.assertEquals(len(data["results"][0]["posts"]), 5) + + def test_rules_with_posts_ordered(self): + rules = { + rule: PostFactory.create_batch(size=5, rule=rule) + for rule in CollectionRuleFactory.create_batch(size=2, user=self.user) + } + + self.client.force_login(self.user) + response = self.client.get(reverse("api:rules-list")) + data = response.json() + + first_post_set = data["results"][0]["posts"] + second_post_set = data["results"][1]["posts"] + + self.assertEquals(response.status_code, 200) + self.assertTrue("results" in data) + self.assertTrue("count" in data) + self.assertEquals(data["count"], 2) + + self.assertEquals(len(data["results"]), 2) + + for result_set in [first_post_set, second_post_set]: + for count, post in enumerate(result_set): + if count < 1: + continue + + self.assertTrue( + post["publication_date"] < result_set[count - 1]["publication_date"] + ) + + def test_rule_with_unauthenticated_user(self): + CollectionRuleFactory.create_batch(size=3, user=self.user) + + response = self.client.get(reverse("api:rules-list")) + response.json() + + self.assertEquals(response.status_code, 403) + + def test_rule_with_unauthorized_user(self): + other_user = UserFactory() + CollectionRuleFactory.create_batch(size=3, user=other_user) + + self.client.force_login(self.user) + response = self.client.get(reverse("api:rules-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + + self.assertEquals(data["count"], 0) + self.assertEquals(len(data["results"]), 0) diff --git a/src/newsreader/news/collection/tests/factories.py b/src/newsreader/news/collection/tests/factories.py index be54806..9d0803f 100644 --- a/src/newsreader/news/collection/tests/factories.py +++ b/src/newsreader/news/collection/tests/factories.py @@ -1,13 +1,17 @@ import factory +from newsreader.auth.tests.factories import UserFactory from newsreader.news.collection.models import CollectionRule class CollectionRuleFactory(factory.django.DjangoModelFactory): - class Meta: - model = CollectionRule - name = factory.Sequence(lambda n: "CollectionRule-{}".format(n)) - source = factory.Faker("name") url = factory.Faker("url") website_url = factory.Faker("url") + + category = factory.SubFactory("newsreader.news.core.tests.factories.CategoryFactory") + + user = factory.SubFactory(UserFactory) + + class Meta: + model = CollectionRule diff --git a/src/newsreader/news/collection/tests/favicon/__init__.py b/src/newsreader/news/collection/tests/favicon/__init__.py index 5fb0299..e69de29 100644 --- a/src/newsreader/news/collection/tests/favicon/__init__.py +++ b/src/newsreader/news/collection/tests/favicon/__init__.py @@ -1,3 +0,0 @@ -from .builder import * -from .client import * -from .collector import * diff --git a/src/newsreader/news/collection/tests/favicon/builder/__init__.py b/src/newsreader/news/collection/tests/favicon/builder/__init__.py index 8baa6e5..e69de29 100644 --- a/src/newsreader/news/collection/tests/favicon/builder/__init__.py +++ b/src/newsreader/news/collection/tests/favicon/builder/__init__.py @@ -1 +0,0 @@ -from .tests import * diff --git a/src/newsreader/news/collection/tests/favicon/builder/tests.py b/src/newsreader/news/collection/tests/favicon/builder/tests.py index c8bd14c..2e7b57a 100644 --- a/src/newsreader/news/collection/tests/favicon/builder/tests.py +++ b/src/newsreader/news/collection/tests/favicon/builder/tests.py @@ -1,7 +1,5 @@ from django.test import TestCase -from freezegun import freeze_time - from newsreader.news.collection.favicon import FaviconBuilder from newsreader.news.collection.tests.factories import CollectionRuleFactory from newsreader.news.collection.tests.favicon.builder.mocks import * diff --git a/src/newsreader/news/collection/tests/favicon/client/__init__.py b/src/newsreader/news/collection/tests/favicon/client/__init__.py index 8baa6e5..e69de29 100644 --- a/src/newsreader/news/collection/tests/favicon/client/__init__.py +++ b/src/newsreader/news/collection/tests/favicon/client/__init__.py @@ -1 +0,0 @@ -from .tests import * diff --git a/src/newsreader/news/collection/tests/favicon/client/tests.py b/src/newsreader/news/collection/tests/favicon/client/tests.py index 4ac2a40..717ee0c 100644 --- a/src/newsreader/news/collection/tests/favicon/client/tests.py +++ b/src/newsreader/news/collection/tests/favicon/client/tests.py @@ -2,7 +2,6 @@ from unittest.mock import MagicMock from django.test import TestCase -from newsreader.news.collection.base import WebsiteStream from newsreader.news.collection.exceptions import ( StreamDeniedException, StreamException, diff --git a/src/newsreader/news/collection/tests/favicon/collector/__init__.py b/src/newsreader/news/collection/tests/favicon/collector/__init__.py index 8baa6e5..e69de29 100644 --- a/src/newsreader/news/collection/tests/favicon/collector/__init__.py +++ b/src/newsreader/news/collection/tests/favicon/collector/__init__.py @@ -1 +0,0 @@ -from .tests import * diff --git a/src/newsreader/news/collection/tests/favicon/collector/tests.py b/src/newsreader/news/collection/tests/favicon/collector/tests.py index a292c16..48c16e7 100644 --- a/src/newsreader/news/collection/tests/favicon/collector/tests.py +++ b/src/newsreader/news/collection/tests/favicon/collector/tests.py @@ -1,9 +1,6 @@ from unittest.mock import MagicMock, patch from django.test import TestCase -from django.utils import timezone - -import pytz from bs4 import BeautifulSoup diff --git a/src/newsreader/news/collection/tests/feed/__init__.py b/src/newsreader/news/collection/tests/feed/__init__.py index 50cea54..e69de29 100644 --- a/src/newsreader/news/collection/tests/feed/__init__.py +++ b/src/newsreader/news/collection/tests/feed/__init__.py @@ -1,5 +0,0 @@ -from .builder import * -from .client import * -from .collector import * -from .duplicate_handler import * -from .stream import * diff --git a/src/newsreader/news/collection/tests/feed/builder/__init__.py b/src/newsreader/news/collection/tests/feed/builder/__init__.py index 8baa6e5..e69de29 100644 --- a/src/newsreader/news/collection/tests/feed/builder/__init__.py +++ b/src/newsreader/news/collection/tests/feed/builder/__init__.py @@ -1 +0,0 @@ -from .tests import * diff --git a/src/newsreader/news/collection/tests/feed/builder/tests.py b/src/newsreader/news/collection/tests/feed/builder/tests.py index 6efd432..94e84ea 100644 --- a/src/newsreader/news/collection/tests/feed/builder/tests.py +++ b/src/newsreader/news/collection/tests/feed/builder/tests.py @@ -10,8 +10,8 @@ from freezegun import freeze_time from newsreader.news.collection.feed import FeedBuilder from newsreader.news.collection.tests.factories import CollectionRuleFactory -from newsreader.news.posts.models import Post -from newsreader.news.posts.tests.factories import PostFactory +from newsreader.news.core.models import Post +from newsreader.news.core.tests.factories import PostFactory from .mocks import * diff --git a/src/newsreader/news/collection/tests/feed/client/__init__.py b/src/newsreader/news/collection/tests/feed/client/__init__.py index 8baa6e5..e69de29 100644 --- a/src/newsreader/news/collection/tests/feed/client/__init__.py +++ b/src/newsreader/news/collection/tests/feed/client/__init__.py @@ -1 +0,0 @@ -from .tests import * diff --git a/src/newsreader/news/collection/tests/feed/client/tests.py b/src/newsreader/news/collection/tests/feed/client/tests.py index bd9e4eb..9c11cbd 100644 --- a/src/newsreader/news/collection/tests/feed/client/tests.py +++ b/src/newsreader/news/collection/tests/feed/client/tests.py @@ -1,7 +1,6 @@ from unittest.mock import MagicMock, patch from django.test import TestCase -from django.utils import timezone from newsreader.news.collection.exceptions import ( StreamDeniedException, diff --git a/src/newsreader/news/collection/tests/feed/collector/__init__.py b/src/newsreader/news/collection/tests/feed/collector/__init__.py index 8baa6e5..e69de29 100644 --- a/src/newsreader/news/collection/tests/feed/collector/__init__.py +++ b/src/newsreader/news/collection/tests/feed/collector/__init__.py @@ -1 +0,0 @@ -from .tests import * diff --git a/src/newsreader/news/collection/tests/feed/collector/tests.py b/src/newsreader/news/collection/tests/feed/collector/tests.py index 16008dd..6978ee9 100644 --- a/src/newsreader/news/collection/tests/feed/collector/tests.py +++ b/src/newsreader/news/collection/tests/feed/collector/tests.py @@ -20,8 +20,8 @@ from newsreader.news.collection.exceptions import ( from newsreader.news.collection.feed import FeedCollector from newsreader.news.collection.tests.factories import CollectionRuleFactory from newsreader.news.collection.utils import build_publication_date -from newsreader.news.posts.models import Post -from newsreader.news.posts.tests.factories import PostFactory +from newsreader.news.core.models import Post +from newsreader.news.core.tests.factories import PostFactory from .mocks import ( duplicate_mock, diff --git a/src/newsreader/news/collection/tests/feed/duplicate_handler/__init__.py b/src/newsreader/news/collection/tests/feed/duplicate_handler/__init__.py index 8baa6e5..e69de29 100644 --- a/src/newsreader/news/collection/tests/feed/duplicate_handler/__init__.py +++ b/src/newsreader/news/collection/tests/feed/duplicate_handler/__init__.py @@ -1 +0,0 @@ -from .tests import * diff --git a/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py index eff63cc..18b6a99 100644 --- a/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py +++ b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py @@ -3,8 +3,7 @@ from django.utils import timezone from newsreader.news.collection.feed import FeedDuplicateHandler from newsreader.news.collection.tests.factories import CollectionRuleFactory -from newsreader.news.posts.models import Post -from newsreader.news.posts.tests.factories import PostFactory +from newsreader.news.core.tests.factories import PostFactory class FeedDuplicateHandlerTestCase(TestCase): diff --git a/src/newsreader/news/collection/tests/feed/stream/__init__.py b/src/newsreader/news/collection/tests/feed/stream/__init__.py index 8baa6e5..e69de29 100644 --- a/src/newsreader/news/collection/tests/feed/stream/__init__.py +++ b/src/newsreader/news/collection/tests/feed/stream/__init__.py @@ -1 +0,0 @@ -from .tests import * diff --git a/src/newsreader/news/collection/tests/feed/stream/tests.py b/src/newsreader/news/collection/tests/feed/stream/tests.py index 8509156..7c0f203 100644 --- a/src/newsreader/news/collection/tests/feed/stream/tests.py +++ b/src/newsreader/news/collection/tests/feed/stream/tests.py @@ -1,7 +1,6 @@ from unittest.mock import MagicMock, patch from django.test import TestCase -from django.utils import timezone from newsreader.news.collection.exceptions import ( StreamDeniedException, diff --git a/src/newsreader/news/collection/tests/utils/__init__.py b/src/newsreader/news/collection/tests/utils/__init__.py index 8baa6e5..e69de29 100644 --- a/src/newsreader/news/collection/tests/utils/__init__.py +++ b/src/newsreader/news/collection/tests/utils/__init__.py @@ -1 +0,0 @@ -from .tests import * diff --git a/src/newsreader/news/collection/urls.py b/src/newsreader/news/collection/urls.py new file mode 100644 index 0000000..4b59a09 --- /dev/null +++ b/src/newsreader/news/collection/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from newsreader.news.collection.views import ( + CollectionRuleAPIListView, + CollectionRuleDetailView, +) + + +endpoints = [ + path("rules/", CollectionRuleDetailView.as_view(), name="rules-detail"), + path("rules/", CollectionRuleAPIListView.as_view(), name="rules-list"), +] diff --git a/src/newsreader/news/collection/views.py b/src/newsreader/news/collection/views.py index dc1ba72..3de472b 100644 --- a/src/newsreader/news/collection/views.py +++ b/src/newsreader/news/collection/views.py @@ -1,4 +1,24 @@ -from django.shortcuts import render +from rest_framework.generics import ( + ListCreateAPIView, + RetrieveUpdateDestroyAPIView, +) + +from newsreader.core.pagination import ResultSetPagination +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.serializers import CollectionRuleSerializer -# Create your views here. +class CollectionRuleAPIListView(ListCreateAPIView): + queryset = CollectionRule.objects.all() + serializer_class = CollectionRuleSerializer + pagination_class = ResultSetPagination + + def get_queryset(self): + user = self.request.user + return self.queryset.filter(user=user).order_by("-created") + + +class CollectionRuleDetailView(RetrieveUpdateDestroyAPIView): + queryset = CollectionRule.objects.all() + serializer_class = CollectionRuleSerializer + pagination_class = ResultSetPagination diff --git a/src/newsreader/news/core/__init__.py b/src/newsreader/news/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/posts/admin.py b/src/newsreader/news/core/admin.py similarity index 85% rename from src/newsreader/news/posts/admin.py rename to src/newsreader/news/core/admin.py index 3e6940b..3bcfc19 100644 --- a/src/newsreader/news/posts/admin.py +++ b/src/newsreader/news/core/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from newsreader.news.posts.models import Category, Post +from newsreader.news.core.models import Category, Post class PostAdmin(admin.ModelAdmin): @@ -10,7 +10,7 @@ class PostAdmin(admin.ModelAdmin): ordering = ("-publication_date", "title") - fields = ("title", "body", "author", "publication_date", "url", "remote_identifier", "category") + fields = ("title", "body", "author", "publication_date", "url") search_fields = ["title"] diff --git a/src/newsreader/news/core/apps.py b/src/newsreader/news/core/apps.py new file mode 100644 index 0000000..5ef1d60 --- /dev/null +++ b/src/newsreader/news/core/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + name = "core" diff --git a/src/newsreader/news/posts/migrations/0001_initial.py b/src/newsreader/news/core/migrations/0001_initial.py similarity index 54% rename from src/newsreader/news/posts/migrations/0001_initial.py rename to src/newsreader/news/core/migrations/0001_initial.py index c7e7604..5e0ffca 100644 --- a/src/newsreader/news/posts/migrations/0001_initial.py +++ b/src/newsreader/news/core/migrations/0001_initial.py @@ -1,8 +1,8 @@ -# Generated by Django 2.2 on 2019-04-10 20:10 - -import django.db.models.deletion +# Generated by Django 2.2 on 2019-07-05 20:59 from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone class Migration(migrations.Migration): @@ -21,11 +21,11 @@ class Migration(migrations.Migration): auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), - ("created", models.DateTimeField(auto_now_add=True)), + ("created", models.DateTimeField(default=django.utils.timezone.now)), ("modified", models.DateTimeField(auto_now=True)), - ("name", models.CharField(max_length=50)), + ("name", models.CharField(max_length=50, unique=True)), ], - options={"abstract": False}, + options={"verbose_name": "Category", "verbose_name_plural": "Categories"}, ), migrations.CreateModel( name="Post", @@ -36,27 +36,23 @@ class Migration(migrations.Migration): auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), - ("created", models.DateTimeField(auto_now_add=True)), + ("created", models.DateTimeField(default=django.utils.timezone.now)), ("modified", models.DateTimeField(auto_now=True)), - ("title", models.CharField(max_length=200)), - ("body", models.TextField()), - ("source", models.CharField(max_length=200)), - ("publication_date", models.DateTimeField()), - ("url", models.URLField()), - ("remote_identifier", models.CharField(max_length=500)), + ("title", models.CharField(blank=True, max_length=200, null=True)), + ("body", models.TextField(blank=True, null=True)), + ("author", models.CharField(blank=True, max_length=200, null=True)), + ("publication_date", models.DateTimeField(blank=True, null=True)), + ("url", models.URLField(blank=True, max_length=1024, null=True)), ( - "category", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.PROTECT, - to="posts.Category", - ), + "remote_identifier", + models.CharField(blank=True, editable=False, max_length=500, null=True), ), ( "rule", models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to="collection.CollectionRule" + editable=False, + on_delete=django.db.models.deletion.CASCADE, + to="collection.CollectionRule", ), ), ], diff --git a/src/newsreader/news/core/migrations/0002_category_user.py b/src/newsreader/news/core/migrations/0002_category_user.py new file mode 100644 index 0000000..d2fa17f --- /dev/null +++ b/src/newsreader/news/core/migrations/0002_category_user.py @@ -0,0 +1,21 @@ +# Generated by Django 2.2 on 2019-07-07 17:08 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("core", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="category", + name="user", + field=models.ForeignKey(default=None, on_delete="Owner", to=settings.AUTH_USER_MODEL), + preserve_default=False, + ) + ] diff --git a/src/newsreader/news/core/migrations/__init__.py b/src/newsreader/news/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/posts/models.py b/src/newsreader/news/core/models.py similarity index 78% rename from src/newsreader/news/posts/models.py rename to src/newsreader/news/core/models.py index fd2c6c1..704e752 100644 --- a/src/newsreader/news/posts/models.py +++ b/src/newsreader/news/core/models.py @@ -8,14 +8,12 @@ from newsreader.news.collection.models import CollectionRule class Post(TimeStampedModel): title = models.CharField(max_length=200, blank=True, null=True) body = models.TextField(blank=True, null=True) - author = models.CharField(max_length=100, blank=True, null=True) + author = models.CharField(max_length=200, blank=True, null=True) publication_date = models.DateTimeField(blank=True, null=True) - url = models.URLField(blank=True, null=True) + url = models.URLField(max_length=1024, blank=True, null=True) - rule = models.ForeignKey(CollectionRule, on_delete=models.CASCADE) - remote_identifier = models.CharField(max_length=500, blank=True, null=True) - - category = models.ForeignKey("Category", blank=True, null=True, on_delete=models.PROTECT) + rule = models.ForeignKey(CollectionRule, on_delete=models.CASCADE, editable=False) + remote_identifier = models.CharField(max_length=500, blank=True, null=True, editable=False) def __str__(self): return "Post-{}".format(self.pk) @@ -23,6 +21,7 @@ class Post(TimeStampedModel): class Category(TimeStampedModel): name = models.CharField(max_length=50, unique=True) + user = models.ForeignKey("auth.User", _("Owner")) class Meta: verbose_name = _("Category") diff --git a/src/newsreader/news/core/pagination.py b/src/newsreader/news/core/pagination.py new file mode 100644 index 0000000..357ff71 --- /dev/null +++ b/src/newsreader/news/core/pagination.py @@ -0,0 +1,31 @@ +from rest_framework import serializers + +from newsreader.news.posts.models import Category, Post + + +class CategorySerializer(serializers.ModelSerializer): + rules = serializers.SerializerMethodField() + + def get_rules(self, instance): + rules = instance.collectionrule_set.order_by("-modified", "-created") + serializer = CollectionRuleSerializer(rules, many=True) + return serializer.data + + class Meta: + model = Category + fields = ("id", "name", "rules") + + +class PostSerializer(serializers.ModelSerializer): + class Meta: + model = Post + fields = ( + "id", + "title", + "body", + "author", + "publication_date", + "url", + "rule", + "remote_identifier", + ) diff --git a/src/newsreader/news/core/serializers.py b/src/newsreader/news/core/serializers.py new file mode 100644 index 0000000..ca3e93c --- /dev/null +++ b/src/newsreader/news/core/serializers.py @@ -0,0 +1,39 @@ +from rest_framework import serializers + +from newsreader.news import collection +from newsreader.news.core.models import Category, Post + + +class PostSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = Post + fields = ( + "id", + "remote_identifier", + "title", + "body", + "author", + "publication_date", + "url", + "rule", + ) + extra_kwargs = {"rule": {"view_name": "api:rules-detail"}} + + +class CategorySerializer(serializers.HyperlinkedModelSerializer): + rules = serializers.SerializerMethodField() + user = serializers.HiddenField(default=serializers.CurrentUserDefault()) + + def get_rules(self, instance): + request = self.context.get("request") + rules = instance.collectionrule_set.order_by("-modified", "-created") + + serializer = collection.serializers.CollectionRuleSerializer( + rules, context={"request": request}, many=True + ) + return serializer.data + + class Meta: + model = Category + fields = ("id", "name", "rules", "user") + extra_kwargs = {"rules": {"view_name": "api:rules-detail"}} diff --git a/src/newsreader/news/core/tests/__init__.py b/src/newsreader/news/core/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/core/tests/endpoints/__init__.py b/src/newsreader/news/core/tests/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/core/tests/endpoints/category/__init__.py b/src/newsreader/news/core/tests/endpoints/category/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/core/tests/endpoints/category/detail/__init__.py b/src/newsreader/news/core/tests/endpoints/category/detail/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/core/tests/endpoints/category/detail/tests.py b/src/newsreader/news/core/tests/endpoints/category/detail/tests.py new file mode 100644 index 0000000..d65fcb7 --- /dev/null +++ b/src/newsreader/news/core/tests/endpoints/category/detail/tests.py @@ -0,0 +1,168 @@ +import json + +from django.test import Client, TestCase +from django.urls import reverse + +from newsreader.auth.tests.factories import UserFactory +from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.core.tests.factories import CategoryFactory, PostFactory + + +class CategoryDetailViewTestCase(TestCase): + def setUp(self): + self.maxDiff = None + + self.client = Client() + self.user = UserFactory(is_staff=True, password="test") + + def test_simple(self): + category = CategoryFactory(user=self.user) + + self.client.force_login(self.user) + response = self.client.get(reverse("api:categories-detail", args=[category.pk])) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertTrue("id" in data) + self.assertTrue("name" in data) + self.assertTrue("rules" in data) + + def test_not_known(self): + self.client.force_login(self.user) + response = self.client.get(reverse("api:categories-detail", args=[100])) + data = response.json() + + self.assertEquals(response.status_code, 404) + self.assertEquals(data["detail"], "Not found.") + + def test_post(self): + category = CategoryFactory(user=self.user) + + self.client.force_login(self.user) + response = self.client.post(reverse("api:categories-detail", args=[category.pk])) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "POST" not allowed.') + + def test_patch(self): + category = CategoryFactory(name="Clickbait", user=self.user) + + self.client.force_login(self.user) + response = self.client.patch( + reverse("api:categories-detail", args=[category.pk]), + data=json.dumps({"name": "Interesting posts"}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["name"], "Interesting posts") + + def test_identifier_cannot_be_changed(self): + category = CategoryFactory(user=self.user) + + self.client.force_login(self.user) + response = self.client.patch( + reverse("api:categories-detail", args=[category.pk]), + data=json.dumps({"id": 44}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["id"], category.pk) + + def test_put(self): + category = CategoryFactory(name="Clickbait", user=self.user) + + self.client.force_login(self.user) + response = self.client.put( + reverse("api:categories-detail", args=[category.pk]), + data=json.dumps({"name": "Interesting posts"}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["name"], "Interesting posts") + + def test_delete(self): + category = CategoryFactory(user=self.user) + + self.client.force_login(self.user) + response = self.client.delete(reverse("api:categories-detail", args=[category.pk])) + + self.assertEquals(response.status_code, 204) + + def test_rules(self): + category = CategoryFactory(user=self.user) + rules = CollectionRuleFactory.create_batch(size=5, category=category, user=self.user) + + self.client.force_login(self.user) + response = self.client.get(reverse("api:categories-detail", args=[category.pk])) + data = response.json() + + self.assertEquals(response.status_code, 200) + + self.assertTrue("id" in data["rules"][0]) + self.assertTrue("name" in data["rules"][0]) + self.assertTrue("url" in data["rules"][0]) + + def test_rules_with_posts(self): + category = CategoryFactory(user=self.user) + + rules = { + rule.pk: PostFactory.create_batch(size=5, rule=rule) + for rule in CollectionRuleFactory.create_batch( + size=5, category=category, user=self.user + ) + } + + self.client.force_login(self.user) + response = self.client.get(reverse("api:categories-detail", args=[category.pk])) + data = response.json() + + self.assertEquals(response.status_code, 200) + + self.assertEquals(len(data["rules"][0]["posts"]), 5) + + def test_rules_with_posts_ordered(self): + category = CategoryFactory(user=self.user) + + rules = { + rule.pk: PostFactory.create_batch(size=5, rule=rule) + for rule in CollectionRuleFactory.create_batch( + size=5, category=category, user=self.user + ) + } + + self.client.force_login(self.user) + response = self.client.get(reverse("api:categories-detail", args=[category.pk])) + data = response.json() + + self.assertEquals(response.status_code, 200) + + posts = data["rules"][0]["posts"] + + for count, post in enumerate(posts): + if count < 1: + continue + + self.assertTrue(post["publication_date"] < posts[count - 1]["publication_date"]) + + def test_category_with_unauthenticated_user(self): + category = CategoryFactory(user=self.user) + + response = self.client.get(reverse("api:categories-detail", args=[category.pk])) + + self.assertEquals(response.status_code, 403) + + def test_category_with_unauthorized_user(self): + other_user = UserFactory() + category = CategoryFactory(user=other_user) + + self.client.force_login(self.user) + response = self.client.get(reverse("api:categories-detail", args=[category.pk])) + + self.assertEquals(response.status_code, 403) diff --git a/src/newsreader/news/core/tests/endpoints/category/list/__init__.py b/src/newsreader/news/core/tests/endpoints/category/list/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/core/tests/endpoints/category/list/tests.py b/src/newsreader/news/core/tests/endpoints/category/list/tests.py new file mode 100644 index 0000000..050a430 --- /dev/null +++ b/src/newsreader/news/core/tests/endpoints/category/list/tests.py @@ -0,0 +1,186 @@ +import json + +from datetime import date, datetime, time + +from django.test import Client, TestCase +from django.urls import reverse + +import pytz + +from newsreader.auth.tests.factories import UserFactory +from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.core.tests.factories import CategoryFactory, PostFactory + + +class CategoryListViewTestCase(TestCase): + def setUp(self): + self.maxDiff = None + + self.client = Client() + self.user = UserFactory(is_staff=True, password="test") + + def test_simple(self): + CategoryFactory.create_batch(size=3, user=self.user) + + self.client.force_login(self.user) + response = self.client.get(reverse("api:categories-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertTrue("results" in data) + self.assertTrue("count" in data) + self.assertEquals(data["count"], 3) + + def test_ordering(self): + categories = [ + CategoryFactory( + created=datetime.combine( + date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc + ), + user=self.user, + ), + CategoryFactory( + created=datetime.combine( + date(2019, 7, 20), time(hour=18, minute=7, second=37), pytz.utc + ), + user=self.user, + ), + CategoryFactory( + created=datetime.combine( + date(2019, 7, 20), time(hour=16, minute=7, second=37), pytz.utc + ), + user=self.user, + ), + ] + + self.client.force_login(self.user) + response = self.client.get(reverse("api:categories-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertTrue("results" in data) + self.assertTrue("count" in data) + self.assertEquals(data["count"], 3) + + self.assertEquals(data["results"][0]["id"], categories[1].pk) + self.assertEquals(data["results"][1]["id"], categories[2].pk) + self.assertEquals(data["results"][2]["id"], categories[0].pk) + + def test_empty(self): + self.client.force_login(self.user) + response = self.client.get(reverse("api:categories-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertTrue("results" in data) + self.assertTrue("count" in data) + + self.assertEquals(data["count"], 0) + self.assertEquals(len(data["results"]), 0) + + def test_post(self): + data = {"name": "Tech"} + + self.client.force_login(self.user) + response = self.client.post( + reverse("api:categories-list"), data=json.dumps(data), content_type="application/json" + ) + response_data = response.json() + + self.assertEquals(response.status_code, 201) + self.assertEquals(response_data["name"], "Tech") + + def test_patch(self): + self.client.force_login(self.user) + response = self.client.patch(reverse("api:categories-list")) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "PATCH" not allowed.') + + def test_put(self): + self.client.force_login(self.user) + response = self.client.put(reverse("api:categories-list")) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "PUT" not allowed.') + + def test_delete(self): + self.client.force_login(self.user) + response = self.client.delete(reverse("api:categories-list")) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "DELETE" not allowed.') + + def test_rules(self): + categories = { + category.pk: CollectionRuleFactory.create_batch( + size=5, category=category, user=self.user + ) + for category in CategoryFactory.create_batch(size=5, user=self.user) + } + + self.client.force_login(self.user) + response = self.client.get(reverse("api:categories-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertTrue("results" in data) + self.assertTrue("count" in data) + self.assertEquals(data["count"], 5) + + self.assertEquals(len(data["results"]), 5) + + self.assertEquals(len(data["results"][0]["rules"]), 5) + + self.assertTrue("id" in data["results"][0]["rules"][0]) + self.assertTrue("name" in data["results"][0]["rules"][0]) + self.assertTrue("url" in data["results"][0]["rules"][0]) + self.assertTrue("posts" in data["results"][0]["rules"][0]) + + def test_rules_with_posts(self): + categories = { + category.pk: CollectionRuleFactory.create_batch( + size=5, category=category, user=self.user + ) + for category in CategoryFactory.create_batch(size=5, user=self.user) + } + + for category in categories: + for rule in categories[category]: + PostFactory.create_batch(size=5, rule=rule) + + self.client.force_login(self.user) + response = self.client.get(reverse("api:categories-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertTrue("results" in data) + self.assertTrue("count" in data) + self.assertEquals(data["count"], 5) + + self.assertEquals(len(data["results"]), 5) + + self.assertEquals(len(data["results"][0]["rules"]), 5) + self.assertEquals(len(data["results"][0]["rules"][0]["posts"]), 5) + + def test_categories_with_unauthenticated_user(self): + CategoryFactory.create_batch(size=3, user=self.user) + + response = self.client.get(reverse("api:categories-list")) + + self.assertEquals(response.status_code, 403) + + def test_categories_with_unauthorized_user(self): + other_user = UserFactory() + CategoryFactory.create_batch(size=3, user=other_user) + + self.client.force_login(self.user) + response = self.client.get(reverse("api:categories-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(len(data["results"]), 0) + self.assertEquals(data["count"], 0) diff --git a/src/newsreader/news/core/tests/endpoints/post/__init__.py b/src/newsreader/news/core/tests/endpoints/post/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/core/tests/endpoints/post/detail/__init__.py b/src/newsreader/news/core/tests/endpoints/post/detail/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/core/tests/endpoints/post/detail/tests.py b/src/newsreader/news/core/tests/endpoints/post/detail/tests.py new file mode 100644 index 0000000..e963035 --- /dev/null +++ b/src/newsreader/news/core/tests/endpoints/post/detail/tests.py @@ -0,0 +1,175 @@ +import json + +from django.test import Client, TestCase +from django.urls import reverse + +from newsreader.auth.tests.factories import UserFactory +from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.core.tests.factories import CategoryFactory, PostFactory + + +class PostDetailViewTestCase(TestCase): + def setUp(self): + self.maxDiff = None + self.client = Client() + + self.client = Client() + self.user = UserFactory(is_staff=True, password="test") + + def test_simple(self): + rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = PostFactory(rule=rule) + + self.client.force_login(self.user) + response = self.client.get(reverse("api:posts-detail", args=[post.pk])) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["id"], post.pk) + + self.assertTrue("title" in data) + self.assertTrue("body" in data) + self.assertTrue("author" in data) + self.assertTrue("publication_date" in data) + self.assertTrue("url" in data) + self.assertTrue("rule" in data) + self.assertTrue("remote_identifier" in data) + + def test_not_known(self): + self.client.force_login(self.user) + response = self.client.get(reverse("api:posts-detail", args=[100])) + data = response.json() + + self.assertEquals(response.status_code, 404) + self.assertEquals(data["detail"], "Not found.") + + def test_post(self): + rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = PostFactory(rule=rule) + + self.client.force_login(self.user) + response = self.client.post(reverse("api:posts-detail", args=[post.pk])) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "POST" not allowed.') + + def test_patch(self): + rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = PostFactory(title="This is clickbait for sure", rule=rule) + + self.client.force_login(self.user) + response = self.client.patch( + reverse("api:posts-detail", args=[post.pk]), + data=json.dumps({"title": "This title is very accurate"}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["title"], "This title is very accurate") + + def test_identifier_cannot_be_changed(self): + rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = PostFactory(title="This is clickbait for sure", rule=rule) + + self.client.force_login(self.user) + response = self.client.patch( + reverse("api:posts-detail", args=[post.pk]), + data=json.dumps({"id": 44}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["id"], post.pk) + + def test_rule_cannot_be_changed(self): + rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) + new_rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = PostFactory(title="This is clickbait for sure", rule=rule) + + self.client.force_login(self.user) + response = self.client.patch( + reverse("api:posts-detail", args=[post.pk]), + data=json.dumps({"rule": reverse("api:rules-detail", args=[new_rule.pk])}), + content_type="application/json", + ) + data = response.json() + rule_url = data["rule"] + + self.assertEquals(response.status_code, 200) + + self.assertTrue(rule_url.endswith(reverse("api:rules-detail", args=[rule.pk]))) + + def test_put(self): + rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = PostFactory(title="This is clickbait for sure", rule=rule) + + self.client.force_login(self.user) + response = self.client.put( + reverse("api:posts-detail", args=[post.pk]), + data=json.dumps({"title": "This title is very accurate"}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["title"], "This title is very accurate") + + def test_delete(self): + rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = PostFactory(rule=rule) + + self.client.force_login(self.user) + response = self.client.delete(reverse("api:posts-detail", args=[post.pk])) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "DELETE" not allowed.') + + def test_post_with_unauthenticated_user_without_category(self): + rule = CollectionRuleFactory(user=self.user, category=None) + post = PostFactory(rule=rule) + + response = self.client.get(reverse("api:posts-detail", args=[post.pk])) + + self.assertEquals(response.status_code, 403) + + def test_post_with_unauthenticated_user_with_category(self): + rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = PostFactory(rule=rule) + + response = self.client.get(reverse("api:posts-detail", args=[post.pk])) + + self.assertEquals(response.status_code, 403) + + def test_post_with_unauthorized_user_without_category(self): + other_user = UserFactory() + rule = CollectionRuleFactory(user=other_user, category=None) + post = PostFactory(rule=rule) + + self.client.force_login(self.user) + response = self.client.get(reverse("api:posts-detail", args=[post.pk])) + + self.assertEquals(response.status_code, 403) + + def test_post_with_unauthorized_user_with_category(self): + other_user = UserFactory() + rule = CollectionRuleFactory(user=other_user, category=CategoryFactory(user=other_user)) + post = PostFactory(rule=rule) + + self.client.force_login(self.user) + response = self.client.get(reverse("api:posts-detail", args=[post.pk])) + + self.assertEquals(response.status_code, 403) + + def test_post_with_different_user_for_category_and_rule(self): + other_user = UserFactory() + rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=other_user)) + post = PostFactory(rule=rule) + + self.client.force_login(self.user) + response = self.client.get(reverse("api:posts-detail", args=[post.pk])) + + self.assertEquals(response.status_code, 403) diff --git a/src/newsreader/news/core/tests/endpoints/post/list/__init__.py b/src/newsreader/news/core/tests/endpoints/post/list/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/core/tests/endpoints/post/list/tests.py b/src/newsreader/news/core/tests/endpoints/post/list/tests.py new file mode 100644 index 0000000..ed1c29e --- /dev/null +++ b/src/newsreader/news/core/tests/endpoints/post/list/tests.py @@ -0,0 +1,208 @@ +from datetime import date, datetime, time + +from django.test import Client, TestCase +from django.urls import reverse + +import pytz + +from newsreader.auth.tests.factories import UserFactory +from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.core.tests.factories import CategoryFactory, PostFactory + + +class PostListViewTestCase(TestCase): + def setUp(self): + self.maxDiff = None + + self.client = Client() + self.user = UserFactory(is_staff=True, password="test") + + def test_simple(self): + rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) + PostFactory.create_batch(size=3, rule=rule) + + self.client.force_login(self.user) + response = self.client.get(reverse("api:posts-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertTrue("results" in data) + self.assertTrue("count" in data) + self.assertEquals(data["count"], 3) + + def test_ordering(self): + rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) + + posts = [ + PostFactory( + title="I'm the first post", + rule=rule, + publication_date=datetime.combine( + date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc + ), + ), + PostFactory( + title="I'm the second post", + rule=rule, + publication_date=datetime.combine( + date(2019, 7, 20), time(hour=18, minute=7, second=37), pytz.utc + ), + ), + PostFactory( + title="I'm the third post", + rule=rule, + publication_date=datetime.combine( + date(2019, 7, 20), time(hour=16, minute=7, second=37), pytz.utc + ), + ), + ] + + self.client.force_login(self.user) + response = self.client.get(reverse("api:posts-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertTrue("results" in data) + self.assertTrue("count" in data) + self.assertEquals(data["count"], 3) + + self.assertEquals(data["results"][0]["id"], posts[1].pk) + self.assertEquals(data["results"][1]["id"], posts[2].pk) + self.assertEquals(data["results"][2]["id"], posts[0].pk) + + def test_pagination_count(self): + rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) + PostFactory.create_batch(size=80, rule=rule) + page_size = 50 + + self.client.force_login(self.user) + response = self.client.get(reverse("api:posts-list"), {"count": 50}) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 80) + self.assertEquals(len(data["results"]), page_size) + + def test_empty(self): + self.client.force_login(self.user) + response = self.client.get(reverse("api:posts-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertTrue("results" in data) + self.assertTrue("count" in data) + + self.assertEquals(data["count"], 0) + self.assertEquals(len(data["results"]), 0) + + def test_post(self): + self.client.force_login(self.user) + response = self.client.post(reverse("api:posts-list")) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "POST" not allowed.') + + def test_patch(self): + self.client.force_login(self.user) + response = self.client.patch(reverse("api:posts-list")) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "PATCH" not allowed.') + + def test_put(self): + self.client.force_login(self.user) + response = self.client.put(reverse("api:posts-list")) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "PUT" not allowed.') + + def test_delete(self): + self.client.force_login(self.user) + response = self.client.delete(reverse("api:posts-list")) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "DELETE" not allowed.') + + def test_posts_with_unauthenticated_user_without_category(self): + PostFactory.create_batch(size=3, rule=CollectionRuleFactory(user=self.user)) + + response = self.client.get(reverse("api:posts-list")) + + self.assertEquals(response.status_code, 403) + + def test_posts_with_unauthenticated_user_with_category(self): + category = CategoryFactory(user=self.user) + + PostFactory.create_batch( + size=3, rule=CollectionRuleFactory(user=self.user, category=category) + ) + + response = self.client.get(reverse("api:posts-list")) + + self.assertEquals(response.status_code, 403) + + def test_posts_with_unauthorized_user_without_category(self): + other_user = UserFactory() + + rule = CollectionRuleFactory(user=other_user, category=None) + PostFactory.create_batch(size=3, rule=rule) + + self.client.force_login(self.user) + response = self.client.get(reverse("api:posts-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(len(data["results"]), 0) + self.assertEquals(data["count"], 0) + + def test_posts_with_unauthorized_user_with_category(self): + other_user = UserFactory() + category = CategoryFactory(user=other_user) + + PostFactory.create_batch( + size=3, rule=CollectionRuleFactory(user=other_user, category=category) + ) + + self.client.force_login(self.user) + response = self.client.get(reverse("api:posts-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(len(data["results"]), 0) + self.assertEquals(data["count"], 0) + + # Note that this situation should not be possible, due to the user not being able + # to specify the user when creating categories/rules + def test_posts_with_authorized_rule_unauthorized_category(self): + other_user = UserFactory() + + rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=other_user)) + PostFactory.create_batch(size=3, rule=rule) + + self.client.force_login(self.user) + response = self.client.get(reverse("api:posts-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertTrue("results" in data) + self.assertTrue("count" in data) + self.assertEquals(data["count"], 0) + + def test_posts_with_authorized_user_without_category(self): + UserFactory() + + rule = CollectionRuleFactory(user=self.user, category=None) + PostFactory.create_batch(size=3, rule=rule) + + self.client.force_login(self.user) + response = self.client.get(reverse("api:posts-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertTrue("results" in data) + self.assertTrue("count" in data) + self.assertEquals(data["count"], 3) diff --git a/src/newsreader/news/posts/tests/factories.py b/src/newsreader/news/core/tests/factories.py similarity index 68% rename from src/newsreader/news/posts/tests/factories.py rename to src/newsreader/news/core/tests/factories.py index a961b4f..c4c6d4c 100644 --- a/src/newsreader/news/posts/tests/factories.py +++ b/src/newsreader/news/core/tests/factories.py @@ -1,21 +1,19 @@ import factory import pytz -from newsreader.news.collection.tests.factories import CollectionRuleFactory -from newsreader.news.posts.models import Category, Post +from newsreader.auth.tests.factories import UserFactory +from newsreader.news.core.models import Category, Post class CategoryFactory(factory.django.DjangoModelFactory): + name = factory.Sequence(lambda n: "Category-{}".format(n)) + user = factory.SubFactory(UserFactory) + class Meta: model = Category - name = factory.Sequence(lambda n: "Category-{}".format(n)) - class PostFactory(factory.django.DjangoModelFactory): - class Meta: - model = Post - title = factory.Faker("sentence") body = factory.Faker("paragraph") author = factory.Faker("name") @@ -23,6 +21,7 @@ class PostFactory(factory.django.DjangoModelFactory): url = factory.Faker("url") remote_identifier = factory.Faker("url") - rule = factory.SubFactory(CollectionRuleFactory) + rule = factory.SubFactory("newsreader.news.collection.tests.factories.CollectionRuleFactory") - category = factory.SubFactory(CategoryFactory) + class Meta: + model = Post diff --git a/src/newsreader/news/core/urls.py b/src/newsreader/news/core/urls.py new file mode 100644 index 0000000..c207440 --- /dev/null +++ b/src/newsreader/news/core/urls.py @@ -0,0 +1,16 @@ +from django.urls import path + +from newsreader.news.core.views import ( + DetailCategoryAPIView, + DetailPostAPIView, + ListCategoryAPIView, + ListPostAPIView, +) + + +endpoints = [ + path("posts/", ListPostAPIView.as_view(), name="posts-list"), + path("posts//", DetailPostAPIView.as_view(), name="posts-detail"), + path("categories/", ListCategoryAPIView.as_view(), name="categories-list"), + path("categories//", DetailCategoryAPIView.as_view(), name="categories-detail"), +] diff --git a/src/newsreader/news/core/views.py b/src/newsreader/news/core/views.py new file mode 100644 index 0000000..5631796 --- /dev/null +++ b/src/newsreader/news/core/views.py @@ -0,0 +1,52 @@ +from django.db.models import Q + +from rest_framework.generics import ( + ListAPIView, + ListCreateAPIView, + RetrieveUpdateAPIView, + RetrieveUpdateDestroyAPIView, +) +from rest_framework.permissions import IsAuthenticated + +from newsreader.auth.permissions import IsPostOwner +from newsreader.core.pagination import ( + LargeResultSetPagination, + ResultSetPagination, +) +from newsreader.news.core.models import Category, Post +from newsreader.news.core.serializers import CategorySerializer, PostSerializer + + +class ListPostAPIView(ListAPIView): + queryset = Post.objects.all() + serializer_class = PostSerializer + pagination_class = LargeResultSetPagination + permission_classes = (IsAuthenticated, IsPostOwner) + + def get_queryset(self): + user = self.request.user + initial_queryset = self.queryset.filter(rule__user=user) + return initial_queryset.filter( + Q(rule__category=None) | Q(rule__category__user=user) + ).order_by("rule", "-publication_date", "-created") + + +class DetailPostAPIView(RetrieveUpdateAPIView): + queryset = Post.objects.all() + serializer_class = PostSerializer + permission_classes = (IsAuthenticated, IsPostOwner) + + +class ListCategoryAPIView(ListCreateAPIView): + queryset = Category.objects.all() + serializer_class = CategorySerializer + pagination_class = ResultSetPagination + + def get_queryset(self): + user = self.request.user + return self.queryset.filter(user=user).order_by("-created", "-modified") + + +class DetailCategoryAPIView(RetrieveUpdateDestroyAPIView): + queryset = Category.objects.all() + serializer_class = CategorySerializer diff --git a/src/newsreader/news/posts/apps.py b/src/newsreader/news/posts/apps.py deleted file mode 100644 index d39b3e1..0000000 --- a/src/newsreader/news/posts/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class PostsConfig(AppConfig): - name = "posts" diff --git a/src/newsreader/news/posts/migrations/0002_auto_20190520_2206.py b/src/newsreader/news/posts/migrations/0002_auto_20190520_2206.py deleted file mode 100644 index 4365972..0000000 --- a/src/newsreader/news/posts/migrations/0002_auto_20190520_2206.py +++ /dev/null @@ -1,15 +0,0 @@ -# Generated by Django 2.2 on 2019-05-20 20:06 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [("posts", "0001_initial")] - - operations = [ - migrations.AlterModelOptions( - name="category", - options={"verbose_name": "Category", "verbose_name_plural": "Categories"}, - ) - ] diff --git a/src/newsreader/news/posts/migrations/0003_auto_20190520_2031.py b/src/newsreader/news/posts/migrations/0003_auto_20190520_2031.py deleted file mode 100644 index 0905440..0000000 --- a/src/newsreader/news/posts/migrations/0003_auto_20190520_2031.py +++ /dev/null @@ -1,14 +0,0 @@ -# Generated by Django 2.2 on 2019-05-20 20:31 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [("posts", "0002_auto_20190520_2206")] - - operations = [ - migrations.AlterField( - model_name="category", name="name", field=models.CharField(max_length=50, unique=True) - ) - ] diff --git a/src/newsreader/news/posts/migrations/0004_auto_20190521_1941.py b/src/newsreader/news/posts/migrations/0004_auto_20190521_1941.py deleted file mode 100644 index 4a0ceb2..0000000 --- a/src/newsreader/news/posts/migrations/0004_auto_20190521_1941.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 2.2 on 2019-05-21 19:41 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [("posts", "0003_auto_20190520_2031")] - - operations = [ - migrations.RemoveField(model_name="post", name="source"), - migrations.AddField( - model_name="post", - name="author", - field=models.CharField(blank=True, max_length=100, null=True), - ), - ] diff --git a/src/newsreader/news/posts/migrations/0005_auto_20190608_1054.py b/src/newsreader/news/posts/migrations/0005_auto_20190608_1054.py deleted file mode 100644 index 9a5e03a..0000000 --- a/src/newsreader/news/posts/migrations/0005_auto_20190608_1054.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 2.2 on 2019-06-08 10:54 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [("posts", "0004_auto_20190521_1941")] - - operations = [ - migrations.AlterField(model_name="post", name="body", field=models.TextField(blank=True)), - migrations.AlterField( - model_name="post", - name="remote_identifier", - field=models.CharField(blank=True, max_length=500, null=True), - ), - ] diff --git a/src/newsreader/news/posts/migrations/0006_auto_20190608_1520.py b/src/newsreader/news/posts/migrations/0006_auto_20190608_1520.py deleted file mode 100644 index 7a5f7ec..0000000 --- a/src/newsreader/news/posts/migrations/0006_auto_20190608_1520.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 2.2 on 2019-06-08 15:20 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [("posts", "0005_auto_20190608_1054")] - - operations = [ - migrations.AlterField( - model_name="post", name="body", field=models.TextField(blank=True, null=True) - ), - migrations.AlterField( - model_name="post", - name="publication_date", - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AlterField( - model_name="post", - name="title", - field=models.CharField(blank=True, max_length=200, null=True), - ), - migrations.AlterField( - model_name="post", name="url", field=models.URLField(blank=True, null=True) - ), - ] diff --git a/src/newsreader/news/posts/views.py b/src/newsreader/news/posts/views.py deleted file mode 100644 index dc1ba72..0000000 --- a/src/newsreader/news/posts/views.py +++ /dev/null @@ -1,4 +0,0 @@ -from django.shortcuts import render - - -# Create your views here. diff --git a/src/newsreader/urls.py b/src/newsreader/urls.py index ed82d43..e8a3b87 100644 --- a/src/newsreader/urls.py +++ b/src/newsreader/urls.py @@ -1,5 +1,20 @@ +from django.conf import settings from django.contrib import admin from django.urls import include, path +from newsreader.news.collection.urls import endpoints as collection_endpoints +from newsreader.news.core.urls import endpoints as core_endpoints -urlpatterns = [path("admin/", admin.site.urls)] + +endpoints = collection_endpoints + core_endpoints + +urlpatterns = [ + path("admin/", admin.site.urls, name="admin"), + path("api/", include((endpoints, "api")), name="api"), +] + + +if settings.DEBUG: + import debug_toolbar + + urlpatterns = [path("debug/", include(debug_toolbar.urls))] + urlpatterns From a798b9858c434261553846a286993eb3ed7ab879 Mon Sep 17 00:00:00 2001 From: Sonny Date: Tue, 9 Jul 2019 19:17:39 +0200 Subject: [PATCH 010/422] Set the default content type --- src/newsreader/conf/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 593c42c..bba7a37 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -115,4 +115,5 @@ REST_FRAMEWORK = { "rest_framework.permissions.IsAuthenticated", "newsreader.auth.permissions.IsOwner", ), + "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), } From 88cf5f9bd45c9c499a530cdc8f2152337f3801c3 Mon Sep 17 00:00:00 2001 From: Sonny Date: Tue, 9 Jul 2019 20:33:05 +0200 Subject: [PATCH 011/422] Add gitlab ci settings --- .gitlab-ci.yml | 25 +++++++++++++++++++ .isort.cfg | 2 +- requirements/gitlab.txt | 6 +++++ requirements/testing.txt | 4 +++ src/newsreader/conf/dev.py | 2 +- src/newsreader/conf/gitlab.py | 15 +++++++++++ .../0002_collectionrule_category.py | 3 ++- .../news/collection/response_handler.py | 6 ----- .../collection/tests/feed/collector/tests.py | 7 +----- .../news/collection/tests/utils/tests.py | 7 +----- src/newsreader/news/collection/urls.py | 5 +--- src/newsreader/news/collection/views.py | 5 +--- .../news/core/migrations/0001_initial.py | 3 ++- src/newsreader/news/core/views.py | 5 +--- 14 files changed, 61 insertions(+), 34 deletions(-) create mode 100644 .gitlab-ci.yml create mode 100644 requirements/gitlab.txt create mode 100644 requirements/testing.txt create mode 100644 src/newsreader/conf/gitlab.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..72b07ed --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,25 @@ +services: + - postgres:9.6 + +variables: + POSTGRES_DB: newsreader + POSTGRES_USER: newsreader + +python tests: + image: python:3.7.4-slim-stretch + stage: test + variables: + PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" + cache: + key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG" + paths: + - .cache/pip + - env/ + before_script: + - python3 -m venv env + - source env/bin/activate + - pip install -r requirements/gitlab.txt + script: + - python src/manage.py test newsreader --settings=newsreader.conf.gitlab + - isort -rc src/ --check-only + - black -l 100 --check src/ diff --git a/.isort.cfg b/.isort.cfg index 2b81405..fa6fb9b 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,6 +1,6 @@ [settings] include_trailing_comma = true -line_length = 80 +line_length = 100 multi_line_output = 3 skip = env/, venv/ default_section = THIRDPARTY diff --git a/requirements/gitlab.txt b/requirements/gitlab.txt new file mode 100644 index 0000000..a0b3eca --- /dev/null +++ b/requirements/gitlab.txt @@ -0,0 +1,6 @@ +-r base.txt + +factory-boy==2.12.0 +freezegun==0.3.12 +black==19.3b0 +isort==4.3.20 diff --git a/requirements/testing.txt b/requirements/testing.txt new file mode 100644 index 0000000..0a685e3 --- /dev/null +++ b/requirements/testing.txt @@ -0,0 +1,4 @@ +-r base.txt + +factory-boy==2.12.0 +freezegun==0.3.12 diff --git a/src/newsreader/conf/dev.py b/src/newsreader/conf/dev.py index 7453d52..2b0e050 100644 --- a/src/newsreader/conf/dev.py +++ b/src/newsreader/conf/dev.py @@ -10,6 +10,6 @@ EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" INSTALLED_APPS += ["debug_toolbar", "django_extensions"] try: - pass + from .local import * # noqa except ImportError: pass diff --git a/src/newsreader/conf/gitlab.py b/src/newsreader/conf/gitlab.py new file mode 100644 index 0000000..a50781e --- /dev/null +++ b/src/newsreader/conf/gitlab.py @@ -0,0 +1,15 @@ +from .base import * # noqa + + +DEBUG = True + +EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql_psycopg2", + "NAME": "newsreader", + "USER": "newsreader", + "HOST": "postgres", + } +} diff --git a/src/newsreader/news/collection/migrations/0002_collectionrule_category.py b/src/newsreader/news/collection/migrations/0002_collectionrule_category.py index 2cb1ee4..b9449a7 100644 --- a/src/newsreader/news/collection/migrations/0002_collectionrule_category.py +++ b/src/newsreader/news/collection/migrations/0002_collectionrule_category.py @@ -1,8 +1,9 @@ # Generated by Django 2.2 on 2019-07-05 20:59 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models + class Migration(migrations.Migration): diff --git a/src/newsreader/news/collection/response_handler.py b/src/newsreader/news/collection/response_handler.py index d9598c4..275bc27 100644 --- a/src/newsreader/news/collection/response_handler.py +++ b/src/newsreader/news/collection/response_handler.py @@ -1,12 +1,6 @@ from typing import ContextManager from requests.exceptions import ConnectionError as RequestConnectionError -from requests.exceptions import ( - HTTPError, - RequestException, - SSLError, - TooManyRedirects, -) from newsreader.news.collection.exceptions import ( StreamConnectionError, diff --git a/src/newsreader/news/collection/tests/feed/collector/tests.py b/src/newsreader/news/collection/tests/feed/collector/tests.py index 6978ee9..cb61242 100644 --- a/src/newsreader/news/collection/tests/feed/collector/tests.py +++ b/src/newsreader/news/collection/tests/feed/collector/tests.py @@ -23,12 +23,7 @@ from newsreader.news.collection.utils import build_publication_date from newsreader.news.core.models import Post from newsreader.news.core.tests.factories import PostFactory -from .mocks import ( - duplicate_mock, - empty_mock, - multiple_mock, - multiple_update_mock, -) +from .mocks import duplicate_mock, empty_mock, multiple_mock, multiple_update_mock class FeedCollectorTestCase(TestCase): diff --git a/src/newsreader/news/collection/tests/utils/tests.py b/src/newsreader/news/collection/tests/utils/tests.py index 0362c71..95c5dd2 100644 --- a/src/newsreader/news/collection/tests/utils/tests.py +++ b/src/newsreader/news/collection/tests/utils/tests.py @@ -3,12 +3,7 @@ from unittest.mock import MagicMock, patch from django.test import TestCase from requests.exceptions import ConnectionError as RequestConnectionError -from requests.exceptions import ( - HTTPError, - RequestException, - SSLError, - TooManyRedirects, -) +from requests.exceptions import HTTPError, RequestException, SSLError, TooManyRedirects from newsreader.news.collection.exceptions import ( StreamConnectionError, diff --git a/src/newsreader/news/collection/urls.py b/src/newsreader/news/collection/urls.py index 4b59a09..de289d1 100644 --- a/src/newsreader/news/collection/urls.py +++ b/src/newsreader/news/collection/urls.py @@ -1,9 +1,6 @@ from django.urls import path -from newsreader.news.collection.views import ( - CollectionRuleAPIListView, - CollectionRuleDetailView, -) +from newsreader.news.collection.views import CollectionRuleAPIListView, CollectionRuleDetailView endpoints = [ diff --git a/src/newsreader/news/collection/views.py b/src/newsreader/news/collection/views.py index 3de472b..2c08185 100644 --- a/src/newsreader/news/collection/views.py +++ b/src/newsreader/news/collection/views.py @@ -1,7 +1,4 @@ -from rest_framework.generics import ( - ListCreateAPIView, - RetrieveUpdateDestroyAPIView, -) +from rest_framework.generics import ListCreateAPIView, RetrieveUpdateDestroyAPIView from newsreader.core.pagination import ResultSetPagination from newsreader.news.collection.models import CollectionRule diff --git a/src/newsreader/news/core/migrations/0001_initial.py b/src/newsreader/news/core/migrations/0001_initial.py index 5e0ffca..9d9ebc9 100644 --- a/src/newsreader/news/core/migrations/0001_initial.py +++ b/src/newsreader/news/core/migrations/0001_initial.py @@ -1,9 +1,10 @@ # Generated by Django 2.2 on 2019-07-05 20:59 -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone +from django.db import migrations, models + class Migration(migrations.Migration): diff --git a/src/newsreader/news/core/views.py b/src/newsreader/news/core/views.py index 5631796..a0559be 100644 --- a/src/newsreader/news/core/views.py +++ b/src/newsreader/news/core/views.py @@ -9,10 +9,7 @@ from rest_framework.generics import ( from rest_framework.permissions import IsAuthenticated from newsreader.auth.permissions import IsPostOwner -from newsreader.core.pagination import ( - LargeResultSetPagination, - ResultSetPagination, -) +from newsreader.core.pagination import LargeResultSetPagination, ResultSetPagination from newsreader.news.core.models import Category, Post from newsreader.news.core.serializers import CategorySerializer, PostSerializer From a95db91726e79a96b3fca927a12479509ff9b532 Mon Sep 17 00:00:00 2001 From: Sonny Date: Wed, 10 Jul 2019 21:55:28 +0200 Subject: [PATCH 012/422] Add truncate tests for title and author fields --- src/newsreader/news/collection/feed.py | 32 ++++- .../collection/tests/feed/builder/mocks.py | 129 ++++++++++++++++++ .../collection/tests/feed/builder/tests.py | 28 ++++ .../migrations/0003_auto_20190710_2022.py | 16 +++ src/newsreader/news/core/models.py | 2 +- 5 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 src/newsreader/news/core/migrations/0003_auto_20190710_2022.py diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index a7ab2fc..c7e9b6d 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -1,6 +1,8 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from typing import ContextManager, Dict, Generator, List, Optional, Tuple +from django.db.models.fields import CharField, TextField +from django.template.defaultfilters import truncatechars from django.utils import timezone import bleach @@ -63,16 +65,18 @@ class FeedBuilder(Builder): for entry in entries: data = {"rule_id": rule.pk} - for field, value in field_mapping.items(): + for field, model_field in field_mapping.items(): if field in entry: + value = self.truncate_text(model_field, entry[field]) + if field == "published_parsed": - created, aware_datetime = build_publication_date(entry[field], tz) - data[value] = aware_datetime if created else None + created, aware_datetime = build_publication_date(value, tz) + data[model_field] = aware_datetime if created else None elif field == "summary": - summary = self.sanitize_summary(entry[field]) - data[value] = summary + summary = self.sanitize_summary(value) + data[model_field] = summary else: - data[value] = entry[field] + data[model_field] = value yield Post(**data) @@ -82,6 +86,22 @@ class FeedBuilder(Builder): return bleach.clean(summary, tags=tags, attributes=attrs) if summary else None + def truncate_text(self, field_name, value): + field = Post._meta.get_field(field_name) + max_length = field.max_length + cls = type(field) + + if not value or not max_length: + return value + elif not bool(issubclass(cls, CharField) or issubclass(cls, TextField)): + return value + + if len(value) > max_length: + print(f"Truncated {field_name}") + return truncatechars(value, max_length) + + return value + def save(self) -> None: for post in self.instances: post.save() diff --git a/src/newsreader/news/collection/tests/feed/builder/mocks.py b/src/newsreader/news/collection/tests/feed/builder/mocks.py index a486626..9003f86 100644 --- a/src/newsreader/news/collection/tests/feed/builder/mocks.py +++ b/src/newsreader/news/collection/tests/feed/builder/mocks.py @@ -890,3 +890,132 @@ mock_with_html = { "status": 200, "version": "rss20", } + +mock_with_long_author = { + "bozo": 0, + "encoding": "utf-8", + "entries": [ + { + "author": "A. Author but this author name is way to long for an actual surname.", + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "link": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "links": [ + { + "href": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "1152", + "url": "http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg", + "width": "2048", + } + ], + "published": "Mon, 20 May 2019 16:07:37 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + "summary": "Foreign Minister Mohammad Javad Zarif says the US " + "president should try showing Iranians some respect.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Foreign Minister Mohammad Javad " + "Zarif says the US president should " + "try showing Iranians some " + "respect.", + }, + "title": "Trump's 'genocidal taunts' will not end Iran - Zarif", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif", + }, + } + ], + "feed": { + "image": { + "href": "https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif", + "link": "https://www.bbc.co.uk/news/", + "title": "BBC News - Home", + "language": "en-gb", + "link": "https://www.bbc.co.uk/news/", + }, + "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "title": "BBC News - Home", + }, + "href": "http://feeds.bbci.co.uk/news/rss.xml", + "status": 200, + "version": "rss20", +} + +mock_with_long_title = { + "bozo": 0, + "encoding": "utf-8", + "entries": [ + { + "author": "A. Author", + "guidislink": False, + "href": "", + "id": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "link": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "links": [ + { + "href": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "rel": "alternate", + "type": "text/html", + } + ], + "media_thumbnail": [ + { + "height": "1152", + "url": "http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg", + "width": "2048", + } + ], + "published": "Mon, 20 May 2019 16:07:37 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + "summary": "Foreign Minister Mohammad Javad Zarif says the US " + "president should try showing Iranians some respect.", + "summary_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/html", + "value": "Foreign Minister Mohammad Javad " + "Zarif says the US president should " + "try showing Iranians some " + "respect.", + }, + "title": "Trump's 'genocidal taunts' will not end Iran - Zarif" + "Trump's 'genocidal taunts' will not end Iran - Zarif" + "Trump's 'genocidal taunts' will not end Iran - Zarif" + "Trump's 'genocidal taunts' will not end Iran - Zarif" + "Trump's 'genocidal taunts' will not end Iran - Zarif" + "Trump's 'genocidal taunts' will not end Iran - Zarif", + "title_detail": { + "base": "http://feeds.bbci.co.uk/news/rss.xml", + "language": None, + "type": "text/plain", + "value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif", + }, + } + ], + "feed": { + "image": { + "href": "https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif", + "link": "https://www.bbc.co.uk/news/", + "title": "BBC News - Home", + "language": "en-gb", + "link": "https://www.bbc.co.uk/news/", + }, + "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "title": "BBC News - Home", + }, + "href": "http://feeds.bbci.co.uk/news/rss.xml", + "status": 200, + "version": "rss20", +} diff --git a/src/newsreader/news/collection/tests/feed/builder/tests.py b/src/newsreader/news/collection/tests/feed/builder/tests.py index 94e84ea..33aae7f 100644 --- a/src/newsreader/news/collection/tests/feed/builder/tests.py +++ b/src/newsreader/news/collection/tests/feed/builder/tests.py @@ -265,3 +265,31 @@ class FeedBuilderTestCase(TestCase): self.assertTrue("" not in post.body) self.assertTrue('' in post.body) self.assertTrue("

" in post.body) + + def test_long_author_text_is_truncated(self): + builder = FeedBuilder + rule = CollectionRuleFactory() + mock_stream = MagicMock(rule=rule) + + with builder((mock_with_long_author, mock_stream)) as builder: + builder.save() + + post = Post.objects.get() + + self.assertEquals(Post.objects.count(), 1) + + self.assertEquals(len(post.author), 40) + + def test_long_title_text_is_truncated(self): + builder = FeedBuilder + rule = CollectionRuleFactory() + mock_stream = MagicMock(rule=rule) + + with builder((mock_with_long_title, mock_stream)) as builder: + builder.save() + + post = Post.objects.get() + + self.assertEquals(Post.objects.count(), 1) + + self.assertEquals(len(post.title), 200) diff --git a/src/newsreader/news/core/migrations/0003_auto_20190710_2022.py b/src/newsreader/news/core/migrations/0003_auto_20190710_2022.py new file mode 100644 index 0000000..3c7fe84 --- /dev/null +++ b/src/newsreader/news/core/migrations/0003_auto_20190710_2022.py @@ -0,0 +1,16 @@ +# Generated by Django 2.2 on 2019-07-10 18:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("core", "0002_category_user")] + + operations = [ + migrations.AlterField( + model_name="post", + name="author", + field=models.CharField(blank=True, max_length=40, null=True), + ) + ] diff --git a/src/newsreader/news/core/models.py b/src/newsreader/news/core/models.py index 704e752..c7d2638 100644 --- a/src/newsreader/news/core/models.py +++ b/src/newsreader/news/core/models.py @@ -8,7 +8,7 @@ from newsreader.news.collection.models import CollectionRule class Post(TimeStampedModel): title = models.CharField(max_length=200, blank=True, null=True) body = models.TextField(blank=True, null=True) - author = models.CharField(max_length=200, blank=True, null=True) + author = models.CharField(max_length=40, blank=True, null=True) publication_date = models.DateTimeField(blank=True, null=True) url = models.URLField(max_length=1024, blank=True, null=True) From 8f314e0ca6637a2a1cfdb722a58067430a1558d5 Mon Sep 17 00:00:00 2001 From: Sonny Date: Wed, 10 Jul 2019 22:01:15 +0200 Subject: [PATCH 013/422] Remove leftover print statement --- src/newsreader/news/collection/feed.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index c7e9b6d..ef80365 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -97,7 +97,6 @@ class FeedBuilder(Builder): return value if len(value) > max_length: - print(f"Truncated {field_name}") return truncatechars(value, max_length) return value From 1b774a72088314f3aacc5f6c14f5b95a0fa6b8fe Mon Sep 17 00:00:00 2001 From: Sonny Date: Fri, 12 Jul 2019 20:20:58 +0200 Subject: [PATCH 014/422] Construct datetime without converting to localtime --- src/newsreader/news/collection/feed.py | 2 +- .../news/collection/tests/feed/collector/tests.py | 6 +++--- src/newsreader/news/collection/utils.py | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index ef80365..7237073 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -70,7 +70,7 @@ class FeedBuilder(Builder): value = self.truncate_text(model_field, entry[field]) if field == "published_parsed": - created, aware_datetime = build_publication_date(value, tz) + aware_datetime, created = build_publication_date(value, tz) data[model_field] = aware_datetime if created else None elif field == "summary": summary = self.sanitize_summary(value) diff --git a/src/newsreader/news/collection/tests/feed/collector/tests.py b/src/newsreader/news/collection/tests/feed/collector/tests.py index cb61242..6834b9c 100644 --- a/src/newsreader/news/collection/tests/feed/collector/tests.py +++ b/src/newsreader/news/collection/tests/feed/collector/tests.py @@ -139,7 +139,7 @@ class FeedCollectorTestCase(TestCase): self.mocked_parse.return_value = duplicate_mock rule = CollectionRuleFactory() - _, aware_datetime = build_publication_date( + aware_datetime, _ = build_publication_date( struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), pytz.utc ) @@ -152,7 +152,7 @@ class FeedCollectorTestCase(TestCase): rule=rule, ) - _, aware_datetime = build_publication_date( + aware_datetime, _ = build_publication_date( struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0)), pytz.utc ) @@ -165,7 +165,7 @@ class FeedCollectorTestCase(TestCase): rule=rule, ) - _, aware_datetime = build_publication_date( + aware_datetime, _ = build_publication_date( struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), pytz.utc ) diff --git a/src/newsreader/news/collection/utils.py b/src/newsreader/news/collection/utils.py index 0abc367..83eb708 100644 --- a/src/newsreader/news/collection/utils.py +++ b/src/newsreader/news/collection/utils.py @@ -1,5 +1,5 @@ from datetime import datetime, tzinfo -from time import mktime, struct_time +from time import struct_time from typing import Tuple from django.utils import timezone @@ -14,11 +14,11 @@ from newsreader.news.collection.response_handler import ResponseHandler def build_publication_date(dt: struct_time, tz: tzinfo) -> Tuple: try: - naive_datetime = datetime.fromtimestamp(mktime(dt)) + naive_datetime = datetime(*dt[:6]) published_parsed = timezone.make_aware(naive_datetime, timezone=tz) - except TypeError: - return False, None - return True, published_parsed + except (TypeError, ValueError): + return None, False + return published_parsed, True def fetch(url: str) -> Response: From a74ffae9a798ab37996bb767b52c62bab2aab56b Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 14 Jul 2019 18:44:15 +0200 Subject: [PATCH 015/422] Celery integration --- .gitlab-ci.yml | 5 +- .isort.cfg | 2 +- requirements/base.txt | 2 + src/newsreader/__init__.py | 4 + src/newsreader/{auth => accounts}/__init__.py | 0 src/newsreader/{auth => accounts}/admin.py | 0 src/newsreader/accounts/apps.py | 5 + .../accounts/migrations/0001_initial.py | 151 ++++++++++++++++++ .../migrations/0002_remove_user_username.py | 10 ++ .../migrations/0003_auto_20190714_1417.py | 16 ++ .../migrations/0004_auto_20190714_1501.py | 22 +++ .../{auth => accounts}/migrations/__init__.py | 0 src/newsreader/accounts/models.py | 84 ++++++++++ .../{auth => accounts}/permissions.py | 0 .../{auth => accounts}/tests/__init__.py | 0 .../{auth => accounts}/tests/factories.py | 7 +- src/newsreader/{auth => accounts}/views.py | 0 src/newsreader/auth/apps.py | 5 - src/newsreader/auth/backends.py | 15 -- src/newsreader/auth/models.py | 0 src/newsreader/celery.py | 12 ++ src/newsreader/conf/base.py | 13 +- src/newsreader/news/collection/admin.py | 5 + src/newsreader/news/collection/base.py | 10 +- src/newsreader/news/collection/favicon.py | 4 +- src/newsreader/news/collection/feed.py | 6 +- .../collection/management/commands/collect.py | 3 - .../collection/migrations/0001_initial.py | 56 +++++-- .../migrations/0002_auto_20190714_1036.py | 37 +++++ .../migrations/0003_auto_20190714_1417.py | 19 +++ .../migrations/0003_collectionrule_user.py | 21 --- ...category.py => 0004_auto_20190714_1422.py} | 9 +- src/newsreader/news/collection/models.py | 3 +- src/newsreader/news/collection/serializers.py | 6 +- src/newsreader/news/collection/tasks.py | 19 +++ .../tests/endpoints/rules/detail/tests.py | 10 +- .../tests/endpoints/rules/list/tests.py | 10 +- .../news/collection/tests/factories.py | 2 +- .../collection/tests/favicon/builder/tests.py | 8 +- .../tests/favicon/collector/mocks.py | 8 +- .../tests/favicon/collector/tests.py | 8 +- .../collection/tests/feed/builder/mocks.py | 88 ++++++++-- .../collection/tests/feed/builder/tests.py | 58 +++++-- .../collection/tests/feed/client/mocks.py | 8 +- .../collection/tests/feed/collector/mocks.py | 32 +++- .../collection/tests/feed/collector/tests.py | 8 +- .../collection/tests/feed/stream/mocks.py | 8 +- src/newsreader/news/collection/tests/mocks.py | 8 +- src/newsreader/news/collection/tests/tests.py | 4 +- src/newsreader/news/collection/urls.py | 5 +- .../news/core/migrations/0001_initial.py | 56 ++++--- .../migrations/0002_auto_20190714_1425.py | 31 ++++ .../core/migrations/0002_category_user.py | 21 --- .../migrations/0003_auto_20190710_2022.py | 16 -- src/newsreader/news/core/models.py | 10 +- src/newsreader/news/core/pagination.py | 2 +- src/newsreader/news/core/serializers.py | 2 +- .../tests/endpoints/category/detail/tests.py | 14 +- .../tests/endpoints/category/list/tests.py | 6 +- .../core/tests/endpoints/post/detail/tests.py | 46 ++++-- .../core/tests/endpoints/post/list/tests.py | 18 ++- src/newsreader/news/core/tests/factories.py | 6 +- src/newsreader/news/core/urls.py | 4 +- src/newsreader/news/core/views.py | 2 +- 64 files changed, 829 insertions(+), 221 deletions(-) rename src/newsreader/{auth => accounts}/__init__.py (100%) rename src/newsreader/{auth => accounts}/admin.py (100%) create mode 100644 src/newsreader/accounts/apps.py create mode 100644 src/newsreader/accounts/migrations/0001_initial.py create mode 100644 src/newsreader/accounts/migrations/0002_remove_user_username.py create mode 100644 src/newsreader/accounts/migrations/0003_auto_20190714_1417.py create mode 100644 src/newsreader/accounts/migrations/0004_auto_20190714_1501.py rename src/newsreader/{auth => accounts}/migrations/__init__.py (100%) create mode 100644 src/newsreader/accounts/models.py rename src/newsreader/{auth => accounts}/permissions.py (100%) rename src/newsreader/{auth => accounts}/tests/__init__.py (100%) rename src/newsreader/{auth => accounts}/tests/factories.py (67%) rename src/newsreader/{auth => accounts}/views.py (100%) delete mode 100644 src/newsreader/auth/apps.py delete mode 100644 src/newsreader/auth/backends.py delete mode 100644 src/newsreader/auth/models.py create mode 100644 src/newsreader/celery.py create mode 100644 src/newsreader/news/collection/migrations/0002_auto_20190714_1036.py create mode 100644 src/newsreader/news/collection/migrations/0003_auto_20190714_1417.py delete mode 100644 src/newsreader/news/collection/migrations/0003_collectionrule_user.py rename src/newsreader/news/collection/migrations/{0002_collectionrule_category.py => 0004_auto_20190714_1422.py} (75%) create mode 100644 src/newsreader/news/collection/tasks.py create mode 100644 src/newsreader/news/core/migrations/0002_auto_20190714_1425.py delete mode 100644 src/newsreader/news/core/migrations/0002_category_user.py delete mode 100644 src/newsreader/news/core/migrations/0003_auto_20190710_2022.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 72b07ed..11fc181 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -10,6 +10,7 @@ python tests: stage: test variables: PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" + DJANGO_SETTINGS_MODULE: "newsreader.conf.gitlab" cache: key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG" paths: @@ -20,6 +21,6 @@ python tests: - source env/bin/activate - pip install -r requirements/gitlab.txt script: - - python src/manage.py test newsreader --settings=newsreader.conf.gitlab + - python src/manage.py test newsreader - isort -rc src/ --check-only - - black -l 100 --check src/ + - black -l 90 --check src/ diff --git a/.isort.cfg b/.isort.cfg index fa6fb9b..8d6ccf3 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,6 +1,6 @@ [settings] include_trailing_comma = true -line_length = 100 +line_length = 90 multi_line_output = 3 skip = env/, venv/ default_section = THIRDPARTY diff --git a/requirements/base.txt b/requirements/base.txt index 05be61f..c9686ee 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,8 +1,10 @@ bleach==3.1.0 beautifulsoup4==4.7.1 +celery==4.3.0 certifi==2019.3.9 chardet==3.0.4 Django==2.2 +django-celery-beat==1.5.0 djangorestframework==3.9.4 lxml==4.3.4 feedparser==5.2.1 diff --git a/src/newsreader/__init__.py b/src/newsreader/__init__.py index e69de29..c08c1d6 100644 --- a/src/newsreader/__init__.py +++ b/src/newsreader/__init__.py @@ -0,0 +1,4 @@ +from .celery import app as celery_app + + +__all__ = ["celery_app"] diff --git a/src/newsreader/auth/__init__.py b/src/newsreader/accounts/__init__.py similarity index 100% rename from src/newsreader/auth/__init__.py rename to src/newsreader/accounts/__init__.py diff --git a/src/newsreader/auth/admin.py b/src/newsreader/accounts/admin.py similarity index 100% rename from src/newsreader/auth/admin.py rename to src/newsreader/accounts/admin.py diff --git a/src/newsreader/accounts/apps.py b/src/newsreader/accounts/apps.py new file mode 100644 index 0000000..fb0257e --- /dev/null +++ b/src/newsreader/accounts/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + name = "accounts" diff --git a/src/newsreader/accounts/migrations/0001_initial.py b/src/newsreader/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..1f58ca8 --- /dev/null +++ b/src/newsreader/accounts/migrations/0001_initial.py @@ -0,0 +1,151 @@ +# Generated by Django 2.2 on 2019-07-14 10:36 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("auth", "0011_update_proxy_permissions"), + ("django_celery_beat", "0011_auto_20190508_0153"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=30, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ( + "email", + models.EmailField( + max_length=254, unique=True, verbose_name="email address" + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.Group", + verbose_name="groups", + ), + ), + ( + "task", + models.ForeignKey( + blank=True, + editable=False, + null=True, + on_delete="collection task", + to="django_celery_beat.PeriodicTask", + ), + ), + ( + "task_interval", + models.ForeignKey( + blank=True, + null=True, + on_delete="collection schedule", + to="django_celery_beat.IntervalSchedule", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.Permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, + }, + managers=[("objects", django.contrib.auth.models.UserManager())], + ) + ] diff --git a/src/newsreader/accounts/migrations/0002_remove_user_username.py b/src/newsreader/accounts/migrations/0002_remove_user_username.py new file mode 100644 index 0000000..b6848a3 --- /dev/null +++ b/src/newsreader/accounts/migrations/0002_remove_user_username.py @@ -0,0 +1,10 @@ +# Generated by Django 2.2 on 2019-07-14 10:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [("accounts", "0001_initial")] + + operations = [migrations.RemoveField(model_name="user", name="username")] diff --git a/src/newsreader/accounts/migrations/0003_auto_20190714_1417.py b/src/newsreader/accounts/migrations/0003_auto_20190714_1417.py new file mode 100644 index 0000000..2cbbb0d --- /dev/null +++ b/src/newsreader/accounts/migrations/0003_auto_20190714_1417.py @@ -0,0 +1,16 @@ +# Generated by Django 2.2 on 2019-07-14 14:17 + +from django.db import migrations + +import newsreader.accounts.models + + +class Migration(migrations.Migration): + + dependencies = [("accounts", "0002_remove_user_username")] + + operations = [ + migrations.AlterModelManagers( + name="user", managers=[("objects", newsreader.accounts.models.UserManager())] + ) + ] diff --git a/src/newsreader/accounts/migrations/0004_auto_20190714_1501.py b/src/newsreader/accounts/migrations/0004_auto_20190714_1501.py new file mode 100644 index 0000000..ba2fc84 --- /dev/null +++ b/src/newsreader/accounts/migrations/0004_auto_20190714_1501.py @@ -0,0 +1,22 @@ +# Generated by Django 2.2 on 2019-07-14 15:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("accounts", "0003_auto_20190714_1417")] + + operations = [ + migrations.AlterField( + model_name="user", + name="task", + field=models.OneToOneField( + blank=True, + editable=False, + null=True, + on_delete="collection task", + to="django_celery_beat.PeriodicTask", + ), + ) + ] diff --git a/src/newsreader/auth/migrations/__init__.py b/src/newsreader/accounts/migrations/__init__.py similarity index 100% rename from src/newsreader/auth/migrations/__init__.py rename to src/newsreader/accounts/migrations/__init__.py diff --git a/src/newsreader/accounts/models.py b/src/newsreader/accounts/models.py new file mode 100644 index 0000000..0c29801 --- /dev/null +++ b/src/newsreader/accounts/models.py @@ -0,0 +1,84 @@ +import json + +from django.contrib.auth.models import AbstractUser +from django.contrib.auth.models import UserManager as DjangoUserManager +from django.db import models +from django.utils.translation import gettext as _ + +from django_celery_beat.models import IntervalSchedule, PeriodicTask + + +class UserManager(DjangoUserManager): + def _create_user(self, email, password, **extra_fields): + """ + Create and save a user with the given username, email, and password. + """ + if not email: + raise ValueError("The given email must be set") + email = self.normalize_email(email) + user = self.model(email=email, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_user(self, email, password=None, **extra_fields): + extra_fields.setdefault("is_staff", False) + extra_fields.setdefault("is_superuser", False) + return self._create_user(email, password, **extra_fields) + + def create_superuser(self, email, password, **extra_fields): + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + + if extra_fields.get("is_staff") is not True: + raise ValueError("Superuser must have is_staff=True.") + if extra_fields.get("is_superuser") is not True: + raise ValueError("Superuser must have is_superuser=True.") + + return self._create_user(email, password, **extra_fields) + + +class User(AbstractUser): + email = models.EmailField(_("email address"), unique=True) + + task = models.OneToOneField( + PeriodicTask, _("collection task"), null=True, blank=True, editable=False + ) + task_interval = models.ForeignKey( + IntervalSchedule, _("collection schedule"), null=True, blank=True + ) + + username = None + + objects = UserManager() + + USERNAME_FIELD = "email" + REQUIRED_FIELDS = [] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._original_interval = self.task_interval + + def save(self, *args, **kwargs): + if self._original_interval != self.task_interval: + + if self.task_interval and self.task: + self.task.interval = self.task_interval + self.task.enabled = True + self.task.save() + + elif self.task_interval and not self.task: + self.task = PeriodicTask.objects.create( + enabled=True, + interval=self.task_interval, + name=f"{self.email}-collection-task", + task="newsreader.news.collection.tasks", + args=json.dumps([self.pk]), + kwargs=None, + ) + + elif not self.task_interval and self.task: + self.task.enabled = False + self.task.save() + + super().save(*args, **kwargs) diff --git a/src/newsreader/auth/permissions.py b/src/newsreader/accounts/permissions.py similarity index 100% rename from src/newsreader/auth/permissions.py rename to src/newsreader/accounts/permissions.py diff --git a/src/newsreader/auth/tests/__init__.py b/src/newsreader/accounts/tests/__init__.py similarity index 100% rename from src/newsreader/auth/tests/__init__.py rename to src/newsreader/accounts/tests/__init__.py diff --git a/src/newsreader/auth/tests/factories.py b/src/newsreader/accounts/tests/factories.py similarity index 67% rename from src/newsreader/auth/tests/factories.py rename to src/newsreader/accounts/tests/factories.py index 3975f62..d073c1c 100644 --- a/src/newsreader/auth/tests/factories.py +++ b/src/newsreader/accounts/tests/factories.py @@ -1,11 +1,10 @@ -from django.contrib.auth.models import User - import factory +from newsreader.accounts.models import User + class UserFactory(factory.django.DjangoModelFactory): - username = factory.Sequence(lambda n: f"user-{n}") - email = factory.LazyAttribute(lambda o: f"{o.username}@example.org") + email = factory.Faker("email") password = factory.Faker("password") is_staff = False diff --git a/src/newsreader/auth/views.py b/src/newsreader/accounts/views.py similarity index 100% rename from src/newsreader/auth/views.py rename to src/newsreader/accounts/views.py diff --git a/src/newsreader/auth/apps.py b/src/newsreader/auth/apps.py deleted file mode 100644 index c467d4e..0000000 --- a/src/newsreader/auth/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class AuthConfig(AppConfig): - name = "auth" diff --git a/src/newsreader/auth/backends.py b/src/newsreader/auth/backends.py deleted file mode 100644 index 30a78b9..0000000 --- a/src/newsreader/auth/backends.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.contrib.auth import get_user_model -from django.contrib.auth.backends import ModelBackend - - -class EmailBackend(ModelBackend): - def authenticate(self, request, username=None, password=None, **kwargs): - user_model_class = get_user_model() - - try: - user = user_model_class.objects.get(email=username) - except user_model_class.DoesNotExist: - return - - if user.check_password(password) and self.user_can_authenticate(user): - return user diff --git a/src/newsreader/auth/models.py b/src/newsreader/auth/models.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/newsreader/celery.py b/src/newsreader/celery.py new file mode 100644 index 0000000..4aeb7a1 --- /dev/null +++ b/src/newsreader/celery.py @@ -0,0 +1,12 @@ +import os + +from celery import Celery + + +# note: this should be consistent with the setting from manage.py +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.dev") + +# note: use the --workdir flag when running from different directories +app = Celery("newsreader", broker="amqp://") + +app.autodiscover_tasks() diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index bba7a37..812707b 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -38,7 +38,10 @@ INSTALLED_APPS = [ "django.contrib.staticfiles", # third party apps "rest_framework", + "celery", + "django_celery_beat", # app modules + "newsreader.accounts", "newsreader.news.core", "newsreader.news.collection", ] @@ -92,8 +95,8 @@ AUTH_PASSWORD_VALIDATORS = [ {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] -# Authentication -AUTHENTICATION_BACKENDS = ["newsreader.auth.backends.EmailBackend"] +# Authentication user model +AUTH_USER_MODEL = "accounts.User" # Internationalization # https://docs.djangoproject.com/en/2.2/topics/i18n/ @@ -110,10 +113,12 @@ STATIC_URL = "/static/" # Third party settings REST_FRAMEWORK = { - "DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework.authentication.SessionAuthentication",), + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework.authentication.SessionAuthentication", + ), "DEFAULT_PERMISSION_CLASSES": ( "rest_framework.permissions.IsAuthenticated", - "newsreader.auth.permissions.IsOwner", + "newsreader.accounts.permissions.IsOwner", ), "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), } diff --git a/src/newsreader/news/collection/admin.py b/src/newsreader/news/collection/admin.py index 77ae900..9727b69 100644 --- a/src/newsreader/news/collection/admin.py +++ b/src/newsreader/news/collection/admin.py @@ -8,5 +8,10 @@ class CollectionRuleAdmin(admin.ModelAdmin): list_display = ("name", "category", "url", "last_suceeded", "succeeded") + def save_model(self, request, obj, form, change): + if not change: + obj.user = request.user + obj.save() + admin.site.register(CollectionRule, CollectionRuleAdmin) diff --git a/src/newsreader/news/collection/base.py b/src/newsreader/news/collection/base.py index 1b80256..0524585 100644 --- a/src/newsreader/news/collection/base.py +++ b/src/newsreader/news/collection/base.py @@ -1,4 +1,6 @@ -from typing import ContextManager, Dict, List, Optional, Tuple +from typing import ContextManager, Dict, Optional, Tuple + +from django.db.models.query import QuerySet from bs4 import BeautifulSoup @@ -67,11 +69,13 @@ class Collector: client = None builder = None - def __init__(self, client: Optional[Client] = None, builder: Optional[Builder] = None) -> None: + def __init__( + self, client: Optional[Client] = None, builder: Optional[Builder] = None + ) -> None: self.client = client if client else self.client self.builder = builder if builder else self.builder - def collect(self, rules: Optional[List] = None) -> None: + def collect(self, rules: Optional[QuerySet] = None) -> None: with self.client(rules=rules) as client: for data, stream in client: with self.builder((data, stream)) as builder: diff --git a/src/newsreader/news/collection/favicon.py b/src/newsreader/news/collection/favicon.py index 05bd6c9..f0a5a6b 100644 --- a/src/newsreader/news/collection/favicon.py +++ b/src/newsreader/news/collection/favicon.py @@ -78,7 +78,9 @@ class FaviconClient(Client): def __enter__(self) -> ContextManager: with ThreadPoolExecutor(max_workers=10) as executor: - futures = {executor.submit(stream.read): rule for rule, stream in self.streams} + futures = { + executor.submit(stream.read): rule for rule, stream in self.streams + } for future in as_completed(futures): rule = futures[future] diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index 7237073..2ac4854 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -156,7 +156,7 @@ class FeedCollector(Collector): class FeedDuplicateHandler: def __init__(self, rule: CollectionRule) -> None: - self.queryset = rule.post_set.all() + self.queryset = rule.posts.all() def __enter__(self) -> ContextManager: self.existing_identifiers = self.queryset.filter( @@ -202,7 +202,9 @@ class FeedDuplicateHandler: def handle_duplicate(self, instance: Post) -> Optional[Post]: try: - existing_instance = self.queryset.get(remote_identifier=instance.remote_identifier) + existing_instance = self.queryset.get( + remote_identifier=instance.remote_identifier + ) except ObjectDoesNotExist: return diff --git a/src/newsreader/news/collection/management/commands/collect.py b/src/newsreader/news/collection/management/commands/collect.py index 3da9905..7d928f0 100644 --- a/src/newsreader/news/collection/management/commands/collect.py +++ b/src/newsreader/news/collection/management/commands/collect.py @@ -1,14 +1,11 @@ from django.core.management.base import BaseCommand from newsreader.news.collection.feed import FeedCollector -from newsreader.news.collection.models import CollectionRule class Command(BaseCommand): help = "Collects Atom/RSS feeds" def handle(self, *args, **options): - CollectionRule.objects.all() - collector = FeedCollector() collector.collect() diff --git a/src/newsreader/news/collection/migrations/0001_initial.py b/src/newsreader/news/collection/migrations/0001_initial.py index 00d3d31..51b9396 100644 --- a/src/newsreader/news/collection/migrations/0001_initial.py +++ b/src/newsreader/news/collection/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 2.2 on 2019-07-05 20:59 +# Generated by Django 2.2 on 2019-07-14 10:36 import django.utils.timezone @@ -18,7 +18,10 @@ class Migration(migrations.Migration): ( "id", models.AutoField( - auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", ), ), ("created", models.DateTimeField(default=django.utils.timezone.now)), @@ -27,7 +30,9 @@ class Migration(migrations.Migration): ("url", models.URLField(max_length=1024)), ( "website_url", - models.URLField(blank=True, editable=False, max_length=1024, null=True), + models.URLField( + blank=True, editable=False, max_length=1024, null=True + ), ), ("favicon", models.URLField(blank=True, null=True)), ( @@ -93,8 +98,14 @@ class Migration(migrations.Migration): ("America/Anguilla", "America/Anguilla"), ("America/Antigua", "America/Antigua"), ("America/Araguaina", "America/Araguaina"), - ("America/Argentina/Buenos_Aires", "America/Argentina/Buenos_Aires"), - ("America/Argentina/Catamarca", "America/Argentina/Catamarca"), + ( + "America/Argentina/Buenos_Aires", + "America/Argentina/Buenos_Aires", + ), + ( + "America/Argentina/Catamarca", + "America/Argentina/Catamarca", + ), ( "America/Argentina/ComodRivadavia", "America/Argentina/ComodRivadavia", @@ -103,7 +114,10 @@ class Migration(migrations.Migration): ("America/Argentina/Jujuy", "America/Argentina/Jujuy"), ("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"), ("America/Argentina/Mendoza", "America/Argentina/Mendoza"), - ("America/Argentina/Rio_Gallegos", "America/Argentina/Rio_Gallegos"), + ( + "America/Argentina/Rio_Gallegos", + "America/Argentina/Rio_Gallegos", + ), ("America/Argentina/Salta", "America/Argentina/Salta"), ("America/Argentina/San_Juan", "America/Argentina/San_Juan"), ("America/Argentina/San_Luis", "America/Argentina/San_Luis"), @@ -163,7 +177,10 @@ class Migration(migrations.Migration): ("America/Halifax", "America/Halifax"), ("America/Havana", "America/Havana"), ("America/Hermosillo", "America/Hermosillo"), - ("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"), + ( + "America/Indiana/Indianapolis", + "America/Indiana/Indianapolis", + ), ("America/Indiana/Knox", "America/Indiana/Knox"), ("America/Indiana/Marengo", "America/Indiana/Marengo"), ("America/Indiana/Petersburg", "America/Indiana/Petersburg"), @@ -177,8 +194,14 @@ class Migration(migrations.Migration): ("America/Jamaica", "America/Jamaica"), ("America/Jujuy", "America/Jujuy"), ("America/Juneau", "America/Juneau"), - ("America/Kentucky/Louisville", "America/Kentucky/Louisville"), - ("America/Kentucky/Monticello", "America/Kentucky/Monticello"), + ( + "America/Kentucky/Louisville", + "America/Kentucky/Louisville", + ), + ( + "America/Kentucky/Monticello", + "America/Kentucky/Monticello", + ), ("America/Knox_IN", "America/Knox_IN"), ("America/Kralendijk", "America/Kralendijk"), ("America/La_Paz", "America/La_Paz"), @@ -209,9 +232,18 @@ class Migration(migrations.Migration): ("America/Nipigon", "America/Nipigon"), ("America/Nome", "America/Nome"), ("America/Noronha", "America/Noronha"), - ("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"), - ("America/North_Dakota/Center", "America/North_Dakota/Center"), - ("America/North_Dakota/New_Salem", "America/North_Dakota/New_Salem"), + ( + "America/North_Dakota/Beulah", + "America/North_Dakota/Beulah", + ), + ( + "America/North_Dakota/Center", + "America/North_Dakota/Center", + ), + ( + "America/North_Dakota/New_Salem", + "America/North_Dakota/New_Salem", + ), ("America/Ojinaga", "America/Ojinaga"), ("America/Panama", "America/Panama"), ("America/Pangnirtung", "America/Pangnirtung"), diff --git a/src/newsreader/news/collection/migrations/0002_auto_20190714_1036.py b/src/newsreader/news/collection/migrations/0002_auto_20190714_1036.py new file mode 100644 index 0000000..09c01cf --- /dev/null +++ b/src/newsreader/news/collection/migrations/0002_auto_20190714_1036.py @@ -0,0 +1,37 @@ +# Generated by Django 2.2 on 2019-07-14 10:36 + +import django.db.models.deletion + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("collection", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("core", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="collectionrule", + name="category", + field=models.ForeignKey( + blank=True, + help_text="Posts from this rule will be tagged with this category", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="core.Category", + verbose_name="Category", + ), + ), + migrations.AddField( + model_name="collectionrule", + name="user", + field=models.ForeignKey(on_delete="Owner", to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/src/newsreader/news/collection/migrations/0003_auto_20190714_1417.py b/src/newsreader/news/collection/migrations/0003_auto_20190714_1417.py new file mode 100644 index 0000000..9f86c32 --- /dev/null +++ b/src/newsreader/news/collection/migrations/0003_auto_20190714_1417.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2 on 2019-07-14 14:17 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("collection", "0002_auto_20190714_1036")] + + operations = [ + migrations.AlterField( + model_name="collectionrule", + name="user", + field=models.ForeignKey( + on_delete="Owner", related_name="rules", to=settings.AUTH_USER_MODEL + ), + ) + ] diff --git a/src/newsreader/news/collection/migrations/0003_collectionrule_user.py b/src/newsreader/news/collection/migrations/0003_collectionrule_user.py deleted file mode 100644 index a735ea3..0000000 --- a/src/newsreader/news/collection/migrations/0003_collectionrule_user.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 2.2 on 2019-07-07 17:08 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("collection", "0002_collectionrule_category"), - ] - - operations = [ - migrations.AddField( - model_name="collectionrule", - name="user", - field=models.ForeignKey(default=None, on_delete="Owner", to=settings.AUTH_USER_MODEL), - preserve_default=False, - ) - ] diff --git a/src/newsreader/news/collection/migrations/0002_collectionrule_category.py b/src/newsreader/news/collection/migrations/0004_auto_20190714_1422.py similarity index 75% rename from src/newsreader/news/collection/migrations/0002_collectionrule_category.py rename to src/newsreader/news/collection/migrations/0004_auto_20190714_1422.py index b9449a7..4e9efb2 100644 --- a/src/newsreader/news/collection/migrations/0002_collectionrule_category.py +++ b/src/newsreader/news/collection/migrations/0004_auto_20190714_1422.py @@ -1,4 +1,4 @@ -# Generated by Django 2.2 on 2019-07-05 20:59 +# Generated by Django 2.2 on 2019-07-14 14:22 import django.db.models.deletion @@ -7,12 +7,10 @@ from django.db import migrations, models class Migration(migrations.Migration): - initial = True - - dependencies = [("collection", "0001_initial"), ("core", "0001_initial")] + dependencies = [("collection", "0003_auto_20190714_1417")] operations = [ - migrations.AddField( + migrations.AlterField( model_name="collectionrule", name="category", field=models.ForeignKey( @@ -20,6 +18,7 @@ class Migration(migrations.Migration): help_text="Posts from this rule will be tagged with this category", null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name="rules", to="core.Category", verbose_name="Category", ), diff --git a/src/newsreader/news/collection/models.py b/src/newsreader/news/collection/models.py index d65176a..4432f77 100644 --- a/src/newsreader/news/collection/models.py +++ b/src/newsreader/news/collection/models.py @@ -24,6 +24,7 @@ class CollectionRule(TimeStampedModel): blank=True, null=True, verbose_name=_("Category"), + related_name="rules", help_text=_("Posts from this rule will be tagged with this category"), on_delete=models.SET_NULL, ) @@ -32,7 +33,7 @@ class CollectionRule(TimeStampedModel): succeeded = models.BooleanField(default=False) error = models.CharField(max_length=255, blank=True, null=True) - user = models.ForeignKey("auth.User", _("Owner")) + user = models.ForeignKey("accounts.User", _("Owner"), related_name="rules") def __str__(self): return self.name diff --git a/src/newsreader/news/collection/serializers.py b/src/newsreader/news/collection/serializers.py index cf6f9ea..4f7f3a5 100644 --- a/src/newsreader/news/collection/serializers.py +++ b/src/newsreader/news/collection/serializers.py @@ -10,9 +10,11 @@ class CollectionRuleSerializer(serializers.HyperlinkedModelSerializer): def get_posts(self, instance): request = self.context.get("request") - posts = instance.post_set.order_by("-publication_date") + posts = instance.posts.order_by("-publication_date") - serializer = core.serializers.PostSerializer(posts, context={"request": request}, many=True) + serializer = core.serializers.PostSerializer( + posts, context={"request": request}, many=True + ) return serializer.data class Meta: diff --git a/src/newsreader/news/collection/tasks.py b/src/newsreader/news/collection/tasks.py new file mode 100644 index 0000000..bdf8ffc --- /dev/null +++ b/src/newsreader/news/collection/tasks.py @@ -0,0 +1,19 @@ +from django.core.exceptions import ObjectDoesNotExist + +from newsreader.accounts.models import User +from newsreader.celery import app +from newsreader.news.collection.feed import FeedCollector + + +@app.task +def collect(user_pk): + try: + user = User.objects.get(pk=user_pk) + except ObjectDoesNotExist: + # TODO remove this task + return + + rules = user.rules.all() + + collector = FeedCollector() + collector.collect(rules=rules) diff --git a/src/newsreader/news/collection/tests/endpoints/rules/detail/tests.py b/src/newsreader/news/collection/tests/endpoints/rules/detail/tests.py index 8782533..6a85345 100644 --- a/src/newsreader/news/collection/tests/endpoints/rules/detail/tests.py +++ b/src/newsreader/news/collection/tests/endpoints/rules/detail/tests.py @@ -5,7 +5,7 @@ from urllib.parse import urljoin from django.test import Client, TestCase from django.urls import reverse -from newsreader.auth.tests.factories import UserFactory +from newsreader.accounts.tests.factories import UserFactory from newsreader.news.collection.tests.factories import CollectionRuleFactory from newsreader.news.core.tests.factories import CategoryFactory @@ -130,14 +130,18 @@ class CollectionRuleDetailViewTestCase(TestCase): self.client.force_login(self.user) response = self.client.patch( reverse("api:rules-detail", args=[rule.pk]), - data=json.dumps({"category": reverse("api:categories-detail", args=[category.pk])}), + data=json.dumps( + {"category": reverse("api:categories-detail", args=[category.pk])} + ), content_type="application/json", ) data = response.json() url = data["category"] self.assertEquals(response.status_code, 200) - self.assertTrue(url.endswith(reverse("api:categories-detail", args=[category.pk]))) + self.assertTrue( + url.endswith(reverse("api:categories-detail", args=[category.pk])) + ) def test_put(self): rule = CollectionRuleFactory(name="BBC", user=self.user) diff --git a/src/newsreader/news/collection/tests/endpoints/rules/list/tests.py b/src/newsreader/news/collection/tests/endpoints/rules/list/tests.py index f5a0cea..04c7b73 100644 --- a/src/newsreader/news/collection/tests/endpoints/rules/list/tests.py +++ b/src/newsreader/news/collection/tests/endpoints/rules/list/tests.py @@ -7,7 +7,7 @@ from django.urls import reverse import pytz -from newsreader.auth.tests.factories import UserFactory +from newsreader.accounts.tests.factories import UserFactory from newsreader.news.collection.tests.factories import CollectionRuleFactory from newsreader.news.core.tests.factories import CategoryFactory, PostFactory @@ -100,7 +100,9 @@ class CollectionRuleListViewTestCase(TestCase): self.client.force_login(self.user) response = self.client.post( - reverse("api:rules-list"), data=json.dumps(data), content_type="application/json" + reverse("api:rules-list"), + data=json.dumps(data), + content_type="application/json", ) data = response.json() category_url = data["category"] @@ -110,7 +112,9 @@ class CollectionRuleListViewTestCase(TestCase): self.assertEquals(data["name"], "BBC") self.assertEquals(data["url"], "https://www.bbc.co.uk") - self.assertTrue(category_url.endswith(reverse("api:categories-detail", args=[category.pk]))) + self.assertTrue( + category_url.endswith(reverse("api:categories-detail", args=[category.pk])) + ) def test_patch(self): self.client.force_login(self.user) diff --git a/src/newsreader/news/collection/tests/factories.py b/src/newsreader/news/collection/tests/factories.py index 9d0803f..bddcf1b 100644 --- a/src/newsreader/news/collection/tests/factories.py +++ b/src/newsreader/news/collection/tests/factories.py @@ -1,6 +1,6 @@ import factory -from newsreader.auth.tests.factories import UserFactory +from newsreader.accounts.tests.factories import UserFactory from newsreader.news.collection.models import CollectionRule diff --git a/src/newsreader/news/collection/tests/favicon/builder/tests.py b/src/newsreader/news/collection/tests/favicon/builder/tests.py index 2e7b57a..e8a1a34 100644 --- a/src/newsreader/news/collection/tests/favicon/builder/tests.py +++ b/src/newsreader/news/collection/tests/favicon/builder/tests.py @@ -18,7 +18,9 @@ class FaviconBuilderTestCase(TestCase): self.assertEquals(rule.favicon, "https://www.bbc.com/favicon.ico") def test_without_url(self): - rule = CollectionRuleFactory(website_url="https://www.theguardian.com/", favicon=None) + rule = CollectionRuleFactory( + website_url="https://www.theguardian.com/", favicon=None + ) with FaviconBuilder((rule, mock_without_url)) as builder: builder.build() @@ -39,7 +41,9 @@ class FaviconBuilderTestCase(TestCase): with FaviconBuilder((rule, mock_with_weird_path)) as builder: builder.build() - self.assertEquals(rule.favicon, "https://www.theguardian.com/jabadaba/doe/favicon.ico") + self.assertEquals( + rule.favicon, "https://www.theguardian.com/jabadaba/doe/favicon.ico" + ) def test_other_url(self): rule = CollectionRuleFactory(favicon=None) diff --git a/src/newsreader/news/collection/tests/favicon/collector/mocks.py b/src/newsreader/news/collection/tests/favicon/collector/mocks.py index 097b1dd..3318ffd 100644 --- a/src/newsreader/news/collection/tests/favicon/collector/mocks.py +++ b/src/newsreader/news/collection/tests/favicon/collector/mocks.py @@ -137,7 +137,13 @@ feed_mock = { "link": "https://www.bbc.co.uk/news/", }, "link": "https://www.bbc.co.uk/news/", - "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "links": [ + { + "href": "https://www.bbc.co.uk/news/", + "rel": "alternate", + "type": "text/html", + } + ], "title": "BBC News - Home", }, "href": "http://feeds.bbci.co.uk/news/rss.xml", diff --git a/src/newsreader/news/collection/tests/favicon/collector/tests.py b/src/newsreader/news/collection/tests/favicon/collector/tests.py index 48c16e7..44254a5 100644 --- a/src/newsreader/news/collection/tests/favicon/collector/tests.py +++ b/src/newsreader/news/collection/tests/favicon/collector/tests.py @@ -22,10 +22,14 @@ class FaviconCollectorTestCase(TestCase): def setUp(self): self.maxDiff = None - self.patched_feed_client = patch("newsreader.news.collection.favicon.FeedClient.__enter__") + self.patched_feed_client = patch( + "newsreader.news.collection.favicon.FeedClient.__enter__" + ) self.mocked_feed_client = self.patched_feed_client.start() - self.patched_website_read = patch("newsreader.news.collection.favicon.WebsiteStream.read") + self.patched_website_read = patch( + "newsreader.news.collection.favicon.WebsiteStream.read" + ) self.mocked_website_read = self.patched_website_read.start() def tearDown(self): diff --git a/src/newsreader/news/collection/tests/feed/builder/mocks.py b/src/newsreader/news/collection/tests/feed/builder/mocks.py index 9003f86..945347b 100644 --- a/src/newsreader/news/collection/tests/feed/builder/mocks.py +++ b/src/newsreader/news/collection/tests/feed/builder/mocks.py @@ -57,7 +57,13 @@ simple_mock = { "language": "en-gb", "link": "https://www.bbc.co.uk/news/", }, - "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "links": [ + { + "href": "https://www.bbc.co.uk/news/", + "rel": "alternate", + "type": "text/html", + } + ], "title": "BBC News - Home", }, "href": "http://feeds.bbci.co.uk/news/rss.xml", @@ -201,7 +207,13 @@ multiple_mock = { "language": "en-gb", "link": "https://www.bbc.co.uk/news/", }, - "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "links": [ + { + "href": "https://www.bbc.co.uk/news/", + "rel": "alternate", + "type": "text/html", + } + ], "title": "BBC News - Home", }, "href": "http://feeds.bbci.co.uk/news/rss.xml", @@ -302,7 +314,13 @@ mock_without_identifier = { "language": "en-gb", "link": "https://www.bbc.co.uk/news/", }, - "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "links": [ + { + "href": "https://www.bbc.co.uk/news/", + "rel": "alternate", + "type": "text/html", + } + ], "title": "BBC News - Home", }, "href": "http://feeds.bbci.co.uk/news/rss.xml", @@ -402,7 +420,13 @@ mock_without_publish_date = { "language": "en-gb", "link": "https://www.bbc.co.uk/news/", }, - "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "links": [ + { + "href": "https://www.bbc.co.uk/news/", + "rel": "alternate", + "type": "text/html", + } + ], "title": "BBC News - Home", }, "href": "http://feeds.bbci.co.uk/news/rss.xml", @@ -492,7 +516,13 @@ mock_without_url = { "language": "en-gb", "link": "https://www.bbc.co.uk/news/", }, - "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "links": [ + { + "href": "https://www.bbc.co.uk/news/", + "rel": "alternate", + "type": "text/html", + } + ], "title": "BBC News - Home", }, "href": "http://feeds.bbci.co.uk/news/rss.xml", @@ -575,7 +605,13 @@ mock_without_body = { "language": "en-gb", "link": "https://www.bbc.co.uk/news/", }, - "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "links": [ + { + "href": "https://www.bbc.co.uk/news/", + "rel": "alternate", + "type": "text/html", + } + ], "title": "BBC News - Home", }, "href": "http://feeds.bbci.co.uk/news/rss.xml", @@ -676,7 +712,13 @@ mock_without_author = { "language": "en-gb", "link": "https://www.bbc.co.uk/news/", }, - "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "links": [ + { + "href": "https://www.bbc.co.uk/news/", + "rel": "alternate", + "type": "text/html", + } + ], "title": "BBC News - Home", }, "href": "http://feeds.bbci.co.uk/news/rss.xml", @@ -822,7 +864,13 @@ mock_with_update_entries = { "language": "en-gb", "link": "https://www.bbc.co.uk/news/", }, - "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "links": [ + { + "href": "https://www.bbc.co.uk/news/", + "rel": "alternate", + "type": "text/html", + } + ], "title": "BBC News - Home", }, "href": "http://feeds.bbci.co.uk/news/rss.xml", @@ -883,7 +931,13 @@ mock_with_html = { "language": "en-gb", "link": "https://www.bbc.co.uk/news/", }, - "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "links": [ + { + "href": "https://www.bbc.co.uk/news/", + "rel": "alternate", + "type": "text/html", + } + ], "title": "BBC News - Home", }, "href": "http://feeds.bbci.co.uk/news/rss.xml", @@ -945,7 +999,13 @@ mock_with_long_author = { "language": "en-gb", "link": "https://www.bbc.co.uk/news/", }, - "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "links": [ + { + "href": "https://www.bbc.co.uk/news/", + "rel": "alternate", + "type": "text/html", + } + ], "title": "BBC News - Home", }, "href": "http://feeds.bbci.co.uk/news/rss.xml", @@ -1012,7 +1072,13 @@ mock_with_long_title = { "language": "en-gb", "link": "https://www.bbc.co.uk/news/", }, - "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "links": [ + { + "href": "https://www.bbc.co.uk/news/", + "rel": "alternate", + "type": "text/html", + } + ], "title": "BBC News - Home", }, "href": "http://feeds.bbci.co.uk/news/rss.xml", diff --git a/src/newsreader/news/collection/tests/feed/builder/tests.py b/src/newsreader/news/collection/tests/feed/builder/tests.py index 33aae7f..2f09591 100644 --- a/src/newsreader/news/collection/tests/feed/builder/tests.py +++ b/src/newsreader/news/collection/tests/feed/builder/tests.py @@ -42,7 +42,9 @@ class FeedBuilderTestCase(TestCase): self.assertEquals(post.url, "https://www.bbc.co.uk/news/world-us-canada-48338168") - self.assertEquals(post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif") + self.assertEquals( + post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif" + ) def test_multiple_entries(self): builder = FeedBuilder @@ -64,12 +66,17 @@ class FeedBuilderTestCase(TestCase): self.assertEquals(first_post.publication_date, aware_date) self.assertEquals( - first_post.remote_identifier, "https://www.bbc.co.uk/news/world-us-canada-48338168" + first_post.remote_identifier, + "https://www.bbc.co.uk/news/world-us-canada-48338168", ) - self.assertEquals(first_post.url, "https://www.bbc.co.uk/news/world-us-canada-48338168") + self.assertEquals( + first_post.url, "https://www.bbc.co.uk/news/world-us-canada-48338168" + ) - self.assertEquals(first_post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif") + self.assertEquals( + first_post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif" + ) d = datetime.combine(date(2019, 5, 20), time(hour=12, minute=19, second=19)) aware_date = pytz.utc.localize(d) @@ -77,10 +84,13 @@ class FeedBuilderTestCase(TestCase): self.assertEquals(second_post.publication_date, aware_date) self.assertEquals( - second_post.remote_identifier, "https://www.bbc.co.uk/news/technology-48334739" + second_post.remote_identifier, + "https://www.bbc.co.uk/news/technology-48334739", ) - self.assertEquals(second_post.url, "https://www.bbc.co.uk/news/technology-48334739") + self.assertEquals( + second_post.url, "https://www.bbc.co.uk/news/technology-48334739" + ) self.assertEquals(second_post.title, "Huawei's Android loss: How it affects you") @@ -104,9 +114,13 @@ class FeedBuilderTestCase(TestCase): self.assertEquals(first_post.remote_identifier, None) - self.assertEquals(first_post.url, "https://www.bbc.co.uk/news/world-us-canada-48338168") + self.assertEquals( + first_post.url, "https://www.bbc.co.uk/news/world-us-canada-48338168" + ) - self.assertEquals(first_post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif") + self.assertEquals( + first_post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif" + ) @freeze_time("2019-10-30 12:30:00") def test_entry_without_publication_date(self): @@ -125,12 +139,14 @@ class FeedBuilderTestCase(TestCase): self.assertEquals(first_post.created, timezone.now()) self.assertEquals( - first_post.remote_identifier, "https://www.bbc.co.uk/news/world-us-canada-48338168" + first_post.remote_identifier, + "https://www.bbc.co.uk/news/world-us-canada-48338168", ) self.assertEquals(second_post.created, timezone.now()) self.assertEquals( - second_post.remote_identifier, "https://www.bbc.co.uk/news/technology-48334739" + second_post.remote_identifier, + "https://www.bbc.co.uk/news/technology-48334739", ) @freeze_time("2019-10-30 12:30:00") @@ -150,12 +166,14 @@ class FeedBuilderTestCase(TestCase): self.assertEquals(first_post.created, timezone.now()) self.assertEquals( - first_post.remote_identifier, "https://www.bbc.co.uk/news/world-us-canada-48338168" + first_post.remote_identifier, + "https://www.bbc.co.uk/news/world-us-canada-48338168", ) self.assertEquals(second_post.created, timezone.now()) self.assertEquals( - second_post.remote_identifier, "https://www.bbc.co.uk/news/technology-48334739" + second_post.remote_identifier, + "https://www.bbc.co.uk/news/technology-48334739", ) @freeze_time("2019-10-30 12:30:00") @@ -175,7 +193,8 @@ class FeedBuilderTestCase(TestCase): self.assertEquals(first_post.created, timezone.now()) self.assertEquals( - first_post.remote_identifier, "https://www.bbc.co.uk/news/world-us-canada-48338168" + first_post.remote_identifier, + "https://www.bbc.co.uk/news/world-us-canada-48338168", ) self.assertEquals(second_post.created, timezone.now()) @@ -201,12 +220,14 @@ class FeedBuilderTestCase(TestCase): self.assertEquals(first_post.created, timezone.now()) self.assertEquals( - first_post.remote_identifier, "https://www.bbc.co.uk/news/world-us-canada-48338168" + first_post.remote_identifier, + "https://www.bbc.co.uk/news/world-us-canada-48338168", ) self.assertEquals(second_post.created, timezone.now()) self.assertEquals( - second_post.remote_identifier, "https://www.bbc.co.uk/news/technology-48334739" + second_post.remote_identifier, + "https://www.bbc.co.uk/news/technology-48334739", ) def test_empty_entries(self): @@ -241,10 +262,13 @@ class FeedBuilderTestCase(TestCase): existing_second_post.refresh_from_db() self.assertEquals( - existing_first_post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif" + existing_first_post.title, + "Trump's 'genocidal taunts' will not end Iran - Zarif", ) - self.assertEquals(existing_second_post.title, "Huawei's Android loss: How it affects you") + self.assertEquals( + existing_second_post.title, "Huawei's Android loss: How it affects you" + ) def test_html_sanitizing(self): builder = FeedBuilder diff --git a/src/newsreader/news/collection/tests/feed/client/mocks.py b/src/newsreader/news/collection/tests/feed/client/mocks.py index e055e7b..05283a4 100644 --- a/src/newsreader/news/collection/tests/feed/client/mocks.py +++ b/src/newsreader/news/collection/tests/feed/client/mocks.py @@ -54,7 +54,13 @@ simple_mock = { "language": "en-gb", "link": "https://www.bbc.co.uk/news/", }, - "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "links": [ + { + "href": "https://www.bbc.co.uk/news/", + "rel": "alternate", + "type": "text/html", + } + ], "title": "BBC News - Home", }, "href": "http://feeds.bbci.co.uk/news/rss.xml", diff --git a/src/newsreader/news/collection/tests/feed/collector/mocks.py b/src/newsreader/news/collection/tests/feed/collector/mocks.py index 211f4ef..8ff19b9 100644 --- a/src/newsreader/news/collection/tests/feed/collector/mocks.py +++ b/src/newsreader/news/collection/tests/feed/collector/mocks.py @@ -134,7 +134,13 @@ multiple_mock = { "language": "en-gb", "link": "https://www.bbc.co.uk/news/", }, - "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "links": [ + { + "href": "https://www.bbc.co.uk/news/", + "rel": "alternate", + "type": "text/html", + } + ], "title": "BBC News - Home", }, "href": "http://feeds.bbci.co.uk/news/rss.xml", @@ -154,7 +160,13 @@ empty_mock = { "language": "en-gb", "link": "https://www.bbc.co.uk/news/", }, - "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "links": [ + { + "href": "https://www.bbc.co.uk/news/", + "rel": "alternate", + "type": "text/html", + } + ], "title": "BBC News - Home", }, "href": "http://feeds.bbci.co.uk/news/rss.xml", @@ -292,7 +304,13 @@ duplicate_mock = { "language": "en-gb", "link": "https://www.bbc.co.uk/news/", }, - "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "links": [ + { + "href": "https://www.bbc.co.uk/news/", + "rel": "alternate", + "type": "text/html", + } + ], "title": "BBC News - Home", }, "href": "http://feeds.bbci.co.uk/news/rss.xml", @@ -433,7 +451,13 @@ multiple_update_mock = { "language": "en-gb", "link": "https://www.bbc.co.uk/news/", }, - "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "links": [ + { + "href": "https://www.bbc.co.uk/news/", + "rel": "alternate", + "type": "text/html", + } + ], "title": "BBC News - Home", }, "href": "http://feeds.bbci.co.uk/news/rss.xml", diff --git a/src/newsreader/news/collection/tests/feed/collector/tests.py b/src/newsreader/news/collection/tests/feed/collector/tests.py index 6834b9c..35dd8d8 100644 --- a/src/newsreader/news/collection/tests/feed/collector/tests.py +++ b/src/newsreader/news/collection/tests/feed/collector/tests.py @@ -234,8 +234,12 @@ class FeedCollectorTestCase(TestCase): self.assertEquals(rule.last_suceeded, timezone.now()) self.assertEquals(rule.error, None) - self.assertEquals(first_post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif") + self.assertEquals( + first_post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif" + ) self.assertEquals(second_post.title, "Huawei's Android loss: How it affects you") - self.assertEquals(third_post.title, "Birmingham head teacher threatened over LGBT lessons") + self.assertEquals( + third_post.title, "Birmingham head teacher threatened over LGBT lessons" + ) diff --git a/src/newsreader/news/collection/tests/feed/stream/mocks.py b/src/newsreader/news/collection/tests/feed/stream/mocks.py index 9e7d796..7dfeba6 100644 --- a/src/newsreader/news/collection/tests/feed/stream/mocks.py +++ b/src/newsreader/news/collection/tests/feed/stream/mocks.py @@ -54,7 +54,13 @@ simple_mock = { "language": "en-gb", "link": "https://www.bbc.co.uk/news/", }, - "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "links": [ + { + "href": "https://www.bbc.co.uk/news/", + "rel": "alternate", + "type": "text/html", + } + ], "title": "BBC News - Home", }, "href": "http://feeds.bbci.co.uk/news/rss.xml", diff --git a/src/newsreader/news/collection/tests/mocks.py b/src/newsreader/news/collection/tests/mocks.py index 32ad699..574d3a5 100644 --- a/src/newsreader/news/collection/tests/mocks.py +++ b/src/newsreader/news/collection/tests/mocks.py @@ -20,7 +20,13 @@ simple_feed_mock = { "link": "https://www.bbc.co.uk/news/", }, "link": "https://www.bbc.co.uk/news/", - "links": [{"href": "https://www.bbc.co.uk/news/", "rel": "alternate", "type": "text/html"}], + "links": [ + { + "href": "https://www.bbc.co.uk/news/", + "rel": "alternate", + "type": "text/html", + } + ], "title": "BBC News - Home", }, "href": "http://feeds.bbci.co.uk/news/rss.xml", diff --git a/src/newsreader/news/collection/tests/tests.py b/src/newsreader/news/collection/tests/tests.py index c39abea..363e0b5 100644 --- a/src/newsreader/news/collection/tests/tests.py +++ b/src/newsreader/news/collection/tests/tests.py @@ -118,7 +118,9 @@ class URLBuilderTestCase(TestCase): def test_no_link(self): initial_rule = CollectionRuleFactory() - with URLBuilder((feed_mock_without_link, MagicMock(rule=initial_rule))) as builder: + with URLBuilder( + (feed_mock_without_link, MagicMock(rule=initial_rule)) + ) as builder: rule, url = builder.build() self.assertEquals(rule.pk, initial_rule.pk) diff --git a/src/newsreader/news/collection/urls.py b/src/newsreader/news/collection/urls.py index de289d1..4b59a09 100644 --- a/src/newsreader/news/collection/urls.py +++ b/src/newsreader/news/collection/urls.py @@ -1,6 +1,9 @@ from django.urls import path -from newsreader.news.collection.views import CollectionRuleAPIListView, CollectionRuleDetailView +from newsreader.news.collection.views import ( + CollectionRuleAPIListView, + CollectionRuleDetailView, +) endpoints = [ diff --git a/src/newsreader/news/core/migrations/0001_initial.py b/src/newsreader/news/core/migrations/0001_initial.py index 9d9ebc9..be138d9 100644 --- a/src/newsreader/news/core/migrations/0001_initial.py +++ b/src/newsreader/news/core/migrations/0001_initial.py @@ -1,8 +1,9 @@ -# Generated by Django 2.2 on 2019-07-05 20:59 +# Generated by Django 2.2 on 2019-07-14 10:36 import django.db.models.deletion import django.utils.timezone +from django.conf import settings from django.db import migrations, models @@ -10,43 +11,36 @@ class Migration(migrations.Migration): initial = True - dependencies = [("collection", "0001_initial")] + dependencies = [ + ("collection", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] operations = [ - migrations.CreateModel( - name="Category", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, primary_key=True, serialize=False, verbose_name="ID" - ), - ), - ("created", models.DateTimeField(default=django.utils.timezone.now)), - ("modified", models.DateTimeField(auto_now=True)), - ("name", models.CharField(max_length=50, unique=True)), - ], - options={"verbose_name": "Category", "verbose_name_plural": "Categories"}, - ), migrations.CreateModel( name="Post", fields=[ ( "id", models.AutoField( - auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", ), ), ("created", models.DateTimeField(default=django.utils.timezone.now)), ("modified", models.DateTimeField(auto_now=True)), ("title", models.CharField(blank=True, max_length=200, null=True)), ("body", models.TextField(blank=True, null=True)), - ("author", models.CharField(blank=True, max_length=200, null=True)), + ("author", models.CharField(blank=True, max_length=40, null=True)), ("publication_date", models.DateTimeField(blank=True, null=True)), ("url", models.URLField(blank=True, max_length=1024, null=True)), ( "remote_identifier", - models.CharField(blank=True, editable=False, max_length=500, null=True), + models.CharField( + blank=True, editable=False, max_length=500, null=True + ), ), ( "rule", @@ -59,4 +53,26 @@ class Migration(migrations.Migration): ], options={"abstract": False}, ), + migrations.CreateModel( + name="Category", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created", models.DateTimeField(default=django.utils.timezone.now)), + ("modified", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=50, unique=True)), + ( + "user", + models.ForeignKey(on_delete="Owner", to=settings.AUTH_USER_MODEL), + ), + ], + options={"verbose_name": "Category", "verbose_name_plural": "Categories"}, + ), ] diff --git a/src/newsreader/news/core/migrations/0002_auto_20190714_1425.py b/src/newsreader/news/core/migrations/0002_auto_20190714_1425.py new file mode 100644 index 0000000..acb2d9d --- /dev/null +++ b/src/newsreader/news/core/migrations/0002_auto_20190714_1425.py @@ -0,0 +1,31 @@ +# Generated by Django 2.2 on 2019-07-14 14:25 + +import django.db.models.deletion + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("core", "0001_initial")] + + operations = [ + migrations.AlterField( + model_name="category", + name="user", + field=models.ForeignKey( + on_delete="Owner", related_name="categories", to=settings.AUTH_USER_MODEL + ), + ), + migrations.AlterField( + model_name="post", + name="rule", + field=models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="posts", + to="collection.CollectionRule", + ), + ), + ] diff --git a/src/newsreader/news/core/migrations/0002_category_user.py b/src/newsreader/news/core/migrations/0002_category_user.py deleted file mode 100644 index d2fa17f..0000000 --- a/src/newsreader/news/core/migrations/0002_category_user.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 2.2 on 2019-07-07 17:08 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("core", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="category", - name="user", - field=models.ForeignKey(default=None, on_delete="Owner", to=settings.AUTH_USER_MODEL), - preserve_default=False, - ) - ] diff --git a/src/newsreader/news/core/migrations/0003_auto_20190710_2022.py b/src/newsreader/news/core/migrations/0003_auto_20190710_2022.py deleted file mode 100644 index 3c7fe84..0000000 --- a/src/newsreader/news/core/migrations/0003_auto_20190710_2022.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 2.2 on 2019-07-10 18:22 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [("core", "0002_category_user")] - - operations = [ - migrations.AlterField( - model_name="post", - name="author", - field=models.CharField(blank=True, max_length=40, null=True), - ) - ] diff --git a/src/newsreader/news/core/models.py b/src/newsreader/news/core/models.py index c7d2638..ce5fa16 100644 --- a/src/newsreader/news/core/models.py +++ b/src/newsreader/news/core/models.py @@ -12,8 +12,12 @@ class Post(TimeStampedModel): publication_date = models.DateTimeField(blank=True, null=True) url = models.URLField(max_length=1024, blank=True, null=True) - rule = models.ForeignKey(CollectionRule, on_delete=models.CASCADE, editable=False) - remote_identifier = models.CharField(max_length=500, blank=True, null=True, editable=False) + rule = models.ForeignKey( + CollectionRule, on_delete=models.CASCADE, editable=False, related_name="posts" + ) + remote_identifier = models.CharField( + max_length=500, blank=True, null=True, editable=False + ) def __str__(self): return "Post-{}".format(self.pk) @@ -21,7 +25,7 @@ class Post(TimeStampedModel): class Category(TimeStampedModel): name = models.CharField(max_length=50, unique=True) - user = models.ForeignKey("auth.User", _("Owner")) + user = models.ForeignKey("accounts.User", _("Owner"), related_name="categories") class Meta: verbose_name = _("Category") diff --git a/src/newsreader/news/core/pagination.py b/src/newsreader/news/core/pagination.py index 357ff71..8a09234 100644 --- a/src/newsreader/news/core/pagination.py +++ b/src/newsreader/news/core/pagination.py @@ -7,7 +7,7 @@ class CategorySerializer(serializers.ModelSerializer): rules = serializers.SerializerMethodField() def get_rules(self, instance): - rules = instance.collectionrule_set.order_by("-modified", "-created") + rules = instance.rules.order_by("-modified", "-created") serializer = CollectionRuleSerializer(rules, many=True) return serializer.data diff --git a/src/newsreader/news/core/serializers.py b/src/newsreader/news/core/serializers.py index ca3e93c..cb6eb12 100644 --- a/src/newsreader/news/core/serializers.py +++ b/src/newsreader/news/core/serializers.py @@ -26,7 +26,7 @@ class CategorySerializer(serializers.HyperlinkedModelSerializer): def get_rules(self, instance): request = self.context.get("request") - rules = instance.collectionrule_set.order_by("-modified", "-created") + rules = instance.rules.order_by("-modified", "-created") serializer = collection.serializers.CollectionRuleSerializer( rules, context={"request": request}, many=True diff --git a/src/newsreader/news/core/tests/endpoints/category/detail/tests.py b/src/newsreader/news/core/tests/endpoints/category/detail/tests.py index d65fcb7..251023d 100644 --- a/src/newsreader/news/core/tests/endpoints/category/detail/tests.py +++ b/src/newsreader/news/core/tests/endpoints/category/detail/tests.py @@ -3,7 +3,7 @@ import json from django.test import Client, TestCase from django.urls import reverse -from newsreader.auth.tests.factories import UserFactory +from newsreader.accounts.tests.factories import UserFactory from newsreader.news.collection.tests.factories import CollectionRuleFactory from newsreader.news.core.tests.factories import CategoryFactory, PostFactory @@ -91,13 +91,17 @@ class CategoryDetailViewTestCase(TestCase): category = CategoryFactory(user=self.user) self.client.force_login(self.user) - response = self.client.delete(reverse("api:categories-detail", args=[category.pk])) + response = self.client.delete( + reverse("api:categories-detail", args=[category.pk]) + ) self.assertEquals(response.status_code, 204) def test_rules(self): category = CategoryFactory(user=self.user) - rules = CollectionRuleFactory.create_batch(size=5, category=category, user=self.user) + rules = CollectionRuleFactory.create_batch( + size=5, category=category, user=self.user + ) self.client.force_login(self.user) response = self.client.get(reverse("api:categories-detail", args=[category.pk])) @@ -149,7 +153,9 @@ class CategoryDetailViewTestCase(TestCase): if count < 1: continue - self.assertTrue(post["publication_date"] < posts[count - 1]["publication_date"]) + self.assertTrue( + post["publication_date"] < posts[count - 1]["publication_date"] + ) def test_category_with_unauthenticated_user(self): category = CategoryFactory(user=self.user) diff --git a/src/newsreader/news/core/tests/endpoints/category/list/tests.py b/src/newsreader/news/core/tests/endpoints/category/list/tests.py index 050a430..974645d 100644 --- a/src/newsreader/news/core/tests/endpoints/category/list/tests.py +++ b/src/newsreader/news/core/tests/endpoints/category/list/tests.py @@ -7,7 +7,7 @@ from django.urls import reverse import pytz -from newsreader.auth.tests.factories import UserFactory +from newsreader.accounts.tests.factories import UserFactory from newsreader.news.collection.tests.factories import CollectionRuleFactory from newsreader.news.core.tests.factories import CategoryFactory, PostFactory @@ -83,7 +83,9 @@ class CategoryListViewTestCase(TestCase): self.client.force_login(self.user) response = self.client.post( - reverse("api:categories-list"), data=json.dumps(data), content_type="application/json" + reverse("api:categories-list"), + data=json.dumps(data), + content_type="application/json", ) response_data = response.json() diff --git a/src/newsreader/news/core/tests/endpoints/post/detail/tests.py b/src/newsreader/news/core/tests/endpoints/post/detail/tests.py index e963035..465e2f2 100644 --- a/src/newsreader/news/core/tests/endpoints/post/detail/tests.py +++ b/src/newsreader/news/core/tests/endpoints/post/detail/tests.py @@ -3,7 +3,7 @@ import json from django.test import Client, TestCase from django.urls import reverse -from newsreader.auth.tests.factories import UserFactory +from newsreader.accounts.tests.factories import UserFactory from newsreader.news.collection.tests.factories import CollectionRuleFactory from newsreader.news.core.tests.factories import CategoryFactory, PostFactory @@ -17,7 +17,9 @@ class PostDetailViewTestCase(TestCase): self.user = UserFactory(is_staff=True, password="test") def test_simple(self): - rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) + rule = CollectionRuleFactory( + user=self.user, category=CategoryFactory(user=self.user) + ) post = PostFactory(rule=rule) self.client.force_login(self.user) @@ -44,7 +46,9 @@ class PostDetailViewTestCase(TestCase): self.assertEquals(data["detail"], "Not found.") def test_post(self): - rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) + rule = CollectionRuleFactory( + user=self.user, category=CategoryFactory(user=self.user) + ) post = PostFactory(rule=rule) self.client.force_login(self.user) @@ -55,7 +59,9 @@ class PostDetailViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "POST" not allowed.') def test_patch(self): - rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) + rule = CollectionRuleFactory( + user=self.user, category=CategoryFactory(user=self.user) + ) post = PostFactory(title="This is clickbait for sure", rule=rule) self.client.force_login(self.user) @@ -70,7 +76,9 @@ class PostDetailViewTestCase(TestCase): self.assertEquals(data["title"], "This title is very accurate") def test_identifier_cannot_be_changed(self): - rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) + rule = CollectionRuleFactory( + user=self.user, category=CategoryFactory(user=self.user) + ) post = PostFactory(title="This is clickbait for sure", rule=rule) self.client.force_login(self.user) @@ -85,8 +93,12 @@ class PostDetailViewTestCase(TestCase): self.assertEquals(data["id"], post.pk) def test_rule_cannot_be_changed(self): - rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) - new_rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) + rule = CollectionRuleFactory( + user=self.user, category=CategoryFactory(user=self.user) + ) + new_rule = CollectionRuleFactory( + user=self.user, category=CategoryFactory(user=self.user) + ) post = PostFactory(title="This is clickbait for sure", rule=rule) self.client.force_login(self.user) @@ -103,7 +115,9 @@ class PostDetailViewTestCase(TestCase): self.assertTrue(rule_url.endswith(reverse("api:rules-detail", args=[rule.pk]))) def test_put(self): - rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) + rule = CollectionRuleFactory( + user=self.user, category=CategoryFactory(user=self.user) + ) post = PostFactory(title="This is clickbait for sure", rule=rule) self.client.force_login(self.user) @@ -118,7 +132,9 @@ class PostDetailViewTestCase(TestCase): self.assertEquals(data["title"], "This title is very accurate") def test_delete(self): - rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) + rule = CollectionRuleFactory( + user=self.user, category=CategoryFactory(user=self.user) + ) post = PostFactory(rule=rule) self.client.force_login(self.user) @@ -137,7 +153,9 @@ class PostDetailViewTestCase(TestCase): self.assertEquals(response.status_code, 403) def test_post_with_unauthenticated_user_with_category(self): - rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) + rule = CollectionRuleFactory( + user=self.user, category=CategoryFactory(user=self.user) + ) post = PostFactory(rule=rule) response = self.client.get(reverse("api:posts-detail", args=[post.pk])) @@ -156,7 +174,9 @@ class PostDetailViewTestCase(TestCase): def test_post_with_unauthorized_user_with_category(self): other_user = UserFactory() - rule = CollectionRuleFactory(user=other_user, category=CategoryFactory(user=other_user)) + rule = CollectionRuleFactory( + user=other_user, category=CategoryFactory(user=other_user) + ) post = PostFactory(rule=rule) self.client.force_login(self.user) @@ -166,7 +186,9 @@ class PostDetailViewTestCase(TestCase): def test_post_with_different_user_for_category_and_rule(self): other_user = UserFactory() - rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=other_user)) + rule = CollectionRuleFactory( + user=self.user, category=CategoryFactory(user=other_user) + ) post = PostFactory(rule=rule) self.client.force_login(self.user) diff --git a/src/newsreader/news/core/tests/endpoints/post/list/tests.py b/src/newsreader/news/core/tests/endpoints/post/list/tests.py index ed1c29e..724d8b2 100644 --- a/src/newsreader/news/core/tests/endpoints/post/list/tests.py +++ b/src/newsreader/news/core/tests/endpoints/post/list/tests.py @@ -5,7 +5,7 @@ from django.urls import reverse import pytz -from newsreader.auth.tests.factories import UserFactory +from newsreader.accounts.tests.factories import UserFactory from newsreader.news.collection.tests.factories import CollectionRuleFactory from newsreader.news.core.tests.factories import CategoryFactory, PostFactory @@ -18,7 +18,9 @@ class PostListViewTestCase(TestCase): self.user = UserFactory(is_staff=True, password="test") def test_simple(self): - rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) + rule = CollectionRuleFactory( + user=self.user, category=CategoryFactory(user=self.user) + ) PostFactory.create_batch(size=3, rule=rule) self.client.force_login(self.user) @@ -31,7 +33,9 @@ class PostListViewTestCase(TestCase): self.assertEquals(data["count"], 3) def test_ordering(self): - rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) + rule = CollectionRuleFactory( + user=self.user, category=CategoryFactory(user=self.user) + ) posts = [ PostFactory( @@ -71,7 +75,9 @@ class PostListViewTestCase(TestCase): self.assertEquals(data["results"][2]["id"], posts[0].pk) def test_pagination_count(self): - rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) + rule = CollectionRuleFactory( + user=self.user, category=CategoryFactory(user=self.user) + ) PostFactory.create_batch(size=80, rule=rule) page_size = 50 @@ -180,7 +186,9 @@ class PostListViewTestCase(TestCase): def test_posts_with_authorized_rule_unauthorized_category(self): other_user = UserFactory() - rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=other_user)) + rule = CollectionRuleFactory( + user=self.user, category=CategoryFactory(user=other_user) + ) PostFactory.create_batch(size=3, rule=rule) self.client.force_login(self.user) diff --git a/src/newsreader/news/core/tests/factories.py b/src/newsreader/news/core/tests/factories.py index c4c6d4c..3ccf52d 100644 --- a/src/newsreader/news/core/tests/factories.py +++ b/src/newsreader/news/core/tests/factories.py @@ -1,7 +1,7 @@ import factory import pytz -from newsreader.auth.tests.factories import UserFactory +from newsreader.accounts.tests.factories import UserFactory from newsreader.news.core.models import Category, Post @@ -21,7 +21,9 @@ class PostFactory(factory.django.DjangoModelFactory): url = factory.Faker("url") remote_identifier = factory.Faker("url") - rule = factory.SubFactory("newsreader.news.collection.tests.factories.CollectionRuleFactory") + rule = factory.SubFactory( + "newsreader.news.collection.tests.factories.CollectionRuleFactory" + ) class Meta: model = Post diff --git a/src/newsreader/news/core/urls.py b/src/newsreader/news/core/urls.py index c207440..c5ccaa9 100644 --- a/src/newsreader/news/core/urls.py +++ b/src/newsreader/news/core/urls.py @@ -12,5 +12,7 @@ endpoints = [ path("posts/", ListPostAPIView.as_view(), name="posts-list"), path("posts//", DetailPostAPIView.as_view(), name="posts-detail"), path("categories/", ListCategoryAPIView.as_view(), name="categories-list"), - path("categories//", DetailCategoryAPIView.as_view(), name="categories-detail"), + path( + "categories//", DetailCategoryAPIView.as_view(), name="categories-detail" + ), ] diff --git a/src/newsreader/news/core/views.py b/src/newsreader/news/core/views.py index a0559be..415816c 100644 --- a/src/newsreader/news/core/views.py +++ b/src/newsreader/news/core/views.py @@ -8,7 +8,7 @@ from rest_framework.generics import ( ) from rest_framework.permissions import IsAuthenticated -from newsreader.auth.permissions import IsPostOwner +from newsreader.accounts.permissions import IsPostOwner from newsreader.core.pagination import LargeResultSetPagination, ResultSetPagination from newsreader.news.core.models import Category, Post from newsreader.news.core.serializers import CategorySerializer, PostSerializer From b1c5be61f114fa1ade18794df227941c470b9ce2 Mon Sep 17 00:00:00 2001 From: Sonny Date: Mon, 15 Jul 2019 08:29:24 +0200 Subject: [PATCH 016/422] increase duplicate handler's history range --- src/newsreader/news/collection/feed.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index 2ac4854..09acac0 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -188,13 +188,13 @@ class FeedDuplicateHandler: "publication_date": post.publication_date, } - for existing_post in self.queryset.order_by("-publication_date")[:50]: + for existing_post in self.queryset.order_by("-publication_date")[:500]: if self.is_duplicate(existing_post, values): return True def is_duplicate(self, existing_post: Post, values: Dict) -> bool: for key, value in values.items(): - existing_value = getattr(existing_post, key, object()) + existing_value = getattr(existing_post, key, None) if existing_value != value: return False From 679414a703c2b6de029ec20c701ad1a52bf8e7ce Mon Sep 17 00:00:00 2001 From: Sonny Date: Sat, 20 Jul 2019 09:57:10 +0200 Subject: [PATCH 017/422] Add Login page --- .babelrc | 3 + .gitignore | 6 + gulp/sass.js | 17 + gulpfile.babel.js | 22 + package-lock.json | 6189 +++++++++++++++++ package.json | 40 + .../accounts/templates/accounts/login.html | 24 + src/newsreader/accounts/urls.py | 9 + src/newsreader/accounts/views.py | 17 +- src/newsreader/conf/base.py | 2 +- .../scss/accounts/components/form/_form.scss | 27 + .../scss/accounts/components/form/index.scss | 1 + .../src/scss/accounts/components/index.scss | 2 + .../scss/accounts/components/main/_main.scss | 8 + .../scss/accounts/components/main/index.scss | 1 + .../static/src/scss/accounts/index.scss | 4 + .../src/scss/components/body/_body.scss | 5 + .../src/scss/components/body/index.scss | 1 + .../src/scss/components/button/_button.scss | 28 + .../src/scss/components/button/index.scss | 1 + .../src/scss/components/error/_error.scss | 5 + .../src/scss/components/error/_errorlist.scss | 3 + .../src/scss/components/error/index.scss | 2 + .../src/scss/components/form/_form.scss | 13 + .../src/scss/components/form/index.scss | 1 + .../static/src/scss/components/index.scss | 6 + .../src/scss/components/input/input.scss | 0 .../src/scss/components/main/_main.scss | 4 + .../src/scss/components/main/index.scss | 1 + .../src/scss/components/navbar/_navbar.scss | 43 + .../src/scss/components/navbar/index.scss | 1 + .../static/src/scss/partials/_variables.scss | 26 + src/newsreader/templates/base.html | 33 + src/newsreader/urls.py | 2 + 34 files changed, 6545 insertions(+), 2 deletions(-) create mode 100644 .babelrc create mode 100644 gulp/sass.js create mode 100644 gulpfile.babel.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/newsreader/accounts/templates/accounts/login.html create mode 100644 src/newsreader/accounts/urls.py create mode 100644 src/newsreader/static/src/scss/accounts/components/form/_form.scss create mode 100644 src/newsreader/static/src/scss/accounts/components/form/index.scss create mode 100644 src/newsreader/static/src/scss/accounts/components/index.scss create mode 100644 src/newsreader/static/src/scss/accounts/components/main/_main.scss create mode 100644 src/newsreader/static/src/scss/accounts/components/main/index.scss create mode 100644 src/newsreader/static/src/scss/accounts/index.scss create mode 100644 src/newsreader/static/src/scss/components/body/_body.scss create mode 100644 src/newsreader/static/src/scss/components/body/index.scss create mode 100644 src/newsreader/static/src/scss/components/button/_button.scss create mode 100644 src/newsreader/static/src/scss/components/button/index.scss create mode 100644 src/newsreader/static/src/scss/components/error/_error.scss create mode 100644 src/newsreader/static/src/scss/components/error/_errorlist.scss create mode 100644 src/newsreader/static/src/scss/components/error/index.scss create mode 100644 src/newsreader/static/src/scss/components/form/_form.scss create mode 100644 src/newsreader/static/src/scss/components/form/index.scss create mode 100644 src/newsreader/static/src/scss/components/index.scss create mode 100644 src/newsreader/static/src/scss/components/input/input.scss create mode 100644 src/newsreader/static/src/scss/components/main/_main.scss create mode 100644 src/newsreader/static/src/scss/components/main/index.scss create mode 100644 src/newsreader/static/src/scss/components/navbar/_navbar.scss create mode 100644 src/newsreader/static/src/scss/components/navbar/index.scss create mode 100644 src/newsreader/static/src/scss/partials/_variables.scss create mode 100644 src/newsreader/templates/base.html diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..1320b9a --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["@babel/preset-env"] +} diff --git a/.gitignore b/.gitignore index 8d9e86a..0cc2b5c 100644 --- a/.gitignore +++ b/.gitignore @@ -192,3 +192,9 @@ dmypy.json # Pyre type checker # End of https://www.gitignore.io/api/django,python + +# Javascript +node_modules/ + +# Css +*.css diff --git a/gulp/sass.js b/gulp/sass.js new file mode 100644 index 0000000..637817f --- /dev/null +++ b/gulp/sass.js @@ -0,0 +1,17 @@ +import { src, dest } from "gulp"; + +import concat from "gulp-concat"; +import path from "path"; +import sass from "gulp-sass"; + +const PROJECT_DIR = path.join("src", "newsreader"); +const STATIC_DIR = path.join(PROJECT_DIR, "static"); + +export const ACCOUNTS_DIR = path.join(PROJECT_DIR, "accounts", "static"); + +export default function accountsTask(){ + return src(`${STATIC_DIR}/src/scss/accounts/index.scss`) + .pipe(sass().on("error", sass.logError)) + .pipe(concat("accounts.css")) + .pipe(dest(`${ACCOUNTS_DIR}/accounts/dist/css`)); +}; diff --git a/gulpfile.babel.js b/gulpfile.babel.js new file mode 100644 index 0000000..56a18ee --- /dev/null +++ b/gulpfile.babel.js @@ -0,0 +1,22 @@ +import { series, watch as _watch } from 'gulp'; + +import path from "path"; +import del from "del"; + +import buildSass, { ACCOUNTS_DIR } from "./gulp/sass"; + +const STATIC_DIR = path.join("src", "newsreader", "static"); + +function clean(){ + return del([ + `${ACCOUNTS_DIR}/accounts/dist/css/*`, + ]); +}; + +export function watch(){ + _watch(`${STATIC_DIR}/src/scss/**/*.scss`, (done) => { + series(clean, buildSass)(done); + }); +}; + +export default series(clean, buildSass); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c412ebf --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6189 @@ +{ + "name": "newsreader", + "version": "0.1.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@babel/code-frame": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.0.0.tgz", + "integrity": "sha512-OfC2uemaknXr87bdLUkWog7nYuliM9Ij5HUcajsVcMCpQrcLmtxRbVFTIqmcSkSeYRBFBRxs2FiUqFJDLdiebA==", + "dev": true, + "requires": { + "@babel/highlight": "^7.0.0" + } + }, + "@babel/core": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.5.4.tgz", + "integrity": "sha512-+DaeBEpYq6b2+ZmHx3tHspC+ZRflrvLqwfv8E3hNr5LVQoyBnL8RPKSBCg+rK2W2My9PWlujBiqd0ZPsR9Q6zQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/generator": "^7.5.0", + "@babel/helpers": "^7.5.4", + "@babel/parser": "^7.5.0", + "@babel/template": "^7.4.4", + "@babel/traverse": "^7.5.0", + "@babel/types": "^7.5.0", + "convert-source-map": "^1.1.0", + "debug": "^4.1.0", + "json5": "^2.1.0", + "lodash": "^4.17.11", + "resolve": "^1.3.2", + "semver": "^5.4.1", + "source-map": "^0.5.0" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "@babel/generator": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.5.0.tgz", + "integrity": "sha512-1TTVrt7J9rcG5PMjvO7VEG3FrEoEJNHxumRq66GemPmzboLWtIjjcJgk8rokuAS7IiRSpgVSu5Vb9lc99iJkOA==", + "dev": true, + "requires": { + "@babel/types": "^7.5.0", + "jsesc": "^2.5.1", + "lodash": "^4.17.11", + "source-map": "^0.5.0", + "trim-right": "^1.0.1" + } + }, + "@babel/helper-annotate-as-pure": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.0.0.tgz", + "integrity": "sha512-3UYcJUj9kvSLbLbUIfQTqzcy5VX7GRZ/CCDrnOaZorFFM01aXp1+GJwuFGV4NDDoAS+mOUyHcO6UD/RfqOks3Q==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.1.0.tgz", + "integrity": "sha512-qNSR4jrmJ8M1VMM9tibvyRAHXQs2PmaksQF7c1CGJNipfe3D8p+wgNwgso/P2A2r2mdgBWAXljNWR0QRZAMW8w==", + "dev": true, + "requires": { + "@babel/helper-explode-assignable-expression": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-call-delegate": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/helper-call-delegate/-/helper-call-delegate-7.4.4.tgz", + "integrity": "sha512-l79boDFJ8S1c5hvQvG+rc+wHw6IuH7YldmRKsYtpbawsxURu/paVy57FZMomGK22/JckepaikOkY0MoAmdyOlQ==", + "dev": true, + "requires": { + "@babel/helper-hoist-variables": "^7.4.4", + "@babel/traverse": "^7.4.4", + "@babel/types": "^7.4.4" + } + }, + "@babel/helper-define-map": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/helper-define-map/-/helper-define-map-7.4.4.tgz", + "integrity": "sha512-IX3Ln8gLhZpSuqHJSnTNBWGDE9kdkTEWl21A/K7PQ00tseBwbqCHTvNLHSBd9M0R5rER4h5Rsvj9vw0R5SieBg==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.1.0", + "@babel/types": "^7.4.4", + "lodash": "^4.17.11" + } + }, + "@babel/helper-explode-assignable-expression": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.1.0.tgz", + "integrity": "sha512-NRQpfHrJ1msCHtKjbzs9YcMmJZOg6mQMmGRB+hbamEdG5PNpaSm95275VD92DvJKuyl0s2sFiDmMZ+EnnvufqA==", + "dev": true, + "requires": { + "@babel/traverse": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-function-name": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.1.0.tgz", + "integrity": "sha512-A95XEoCpb3TO+KZzJ4S/5uW5fNe26DjBGqf1o9ucyLyCmi1dXq/B3c8iaWTfBk3VvetUxl16e8tIrd5teOCfGw==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.0.0", + "@babel/template": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0.tgz", + "integrity": "sha512-r2DbJeg4svYvt3HOS74U4eWKsUAMRH01Z1ds1zx8KNTPtpTL5JAsdFv8BNyOpVqdFhHkkRDIg5B4AsxmkjAlmQ==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-hoist-variables": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.4.4.tgz", + "integrity": "sha512-VYk2/H/BnYbZDDg39hr3t2kKyifAm1W6zHRfhx8jGjIHpQEBv9dry7oQ2f3+J703TLu69nYdxsovl0XYfcnK4w==", + "dev": true, + "requires": { + "@babel/types": "^7.4.4" + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.0.0.tgz", + "integrity": "sha512-avo+lm/QmZlv27Zsi0xEor2fKcqWG56D5ae9dzklpIaY7cQMK5N8VSpaNVPPagiqmy7LrEjK1IWdGMOqPu5csg==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-module-imports": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.0.0.tgz", + "integrity": "sha512-aP/hlLq01DWNEiDg4Jn23i+CXxW/owM4WpDLFUbpjxe4NS3BhLVZQ5i7E0ZrxuQ/vwekIeciyamgB1UIYxxM6A==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-module-transforms": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.4.4.tgz", + "integrity": "sha512-3Z1yp8TVQf+B4ynN7WoHPKS8EkdTbgAEy0nU0rs/1Kw4pDgmvYH3rz3aI11KgxKCba2cn7N+tqzV1mY2HMN96w==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.0.0", + "@babel/helper-simple-access": "^7.1.0", + "@babel/helper-split-export-declaration": "^7.4.4", + "@babel/template": "^7.4.4", + "@babel/types": "^7.4.4", + "lodash": "^4.17.11" + } + }, + "@babel/helper-optimise-call-expression": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.0.0.tgz", + "integrity": "sha512-u8nd9NQePYNQV8iPWu/pLLYBqZBa4ZaY1YWRFMuxrid94wKI1QNt67NEZ7GAe5Kc/0LLScbim05xZFWkAdrj9g==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.0.0.tgz", + "integrity": "sha512-CYAOUCARwExnEixLdB6sDm2dIJ/YgEAKDM1MOeMeZu9Ld/bDgVo8aiWrXwcY7OBh+1Ea2uUcVRcxKk0GJvW7QA==", + "dev": true + }, + "@babel/helper-regex": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/helper-regex/-/helper-regex-7.4.4.tgz", + "integrity": "sha512-Y5nuB/kESmR3tKjU8Nkn1wMGEx1tjJX076HBMeL3XLQCu6vA/YRzuTW0bbb+qRnXvQGn+d6Rx953yffl8vEy7Q==", + "dev": true, + "requires": { + "lodash": "^4.17.11" + } + }, + "@babel/helper-remap-async-to-generator": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.1.0.tgz", + "integrity": "sha512-3fOK0L+Fdlg8S5al8u/hWE6vhufGSn0bN09xm2LXMy//REAF8kDCrYoOBKYmA8m5Nom+sV9LyLCwrFynA8/slg==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.0.0", + "@babel/helper-wrap-function": "^7.1.0", + "@babel/template": "^7.1.0", + "@babel/traverse": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-replace-supers": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.4.4.tgz", + "integrity": "sha512-04xGEnd+s01nY1l15EuMS1rfKktNF+1CkKmHoErDppjAAZL+IUBZpzT748x262HF7fibaQPhbvWUl5HeSt1EXg==", + "dev": true, + "requires": { + "@babel/helper-member-expression-to-functions": "^7.0.0", + "@babel/helper-optimise-call-expression": "^7.0.0", + "@babel/traverse": "^7.4.4", + "@babel/types": "^7.4.4" + } + }, + "@babel/helper-simple-access": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.1.0.tgz", + "integrity": "sha512-Vk+78hNjRbsiu49zAPALxTb+JUQCz1aolpd8osOF16BGnLtseD21nbHgLPGUwrXEurZgiCOUmvs3ExTu4F5x6w==", + "dev": true, + "requires": { + "@babel/template": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.4.4.tgz", + "integrity": "sha512-Ro/XkzLf3JFITkW6b+hNxzZ1n5OQ80NvIUdmHspih1XAhtN3vPTuUFT4eQnela+2MaZ5ulH+iyP513KJrxbN7Q==", + "dev": true, + "requires": { + "@babel/types": "^7.4.4" + } + }, + "@babel/helper-wrap-function": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.2.0.tgz", + "integrity": "sha512-o9fP1BZLLSrYlxYEYyl2aS+Flun5gtjTIG8iln+XuEzQTs0PLagAGSXUcqruJwD5fM48jzIEggCKpIfWTcR7pQ==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.1.0", + "@babel/template": "^7.1.0", + "@babel/traverse": "^7.1.0", + "@babel/types": "^7.2.0" + } + }, + "@babel/helpers": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.5.4.tgz", + "integrity": "sha512-6LJ6xwUEJP51w0sIgKyfvFMJvIb9mWAfohJp0+m6eHJigkFdcH8duZ1sfhn0ltJRzwUIT/yqqhdSfRpCpL7oow==", + "dev": true, + "requires": { + "@babel/template": "^7.4.4", + "@babel/traverse": "^7.5.0", + "@babel/types": "^7.5.0" + } + }, + "@babel/highlight": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.5.0.tgz", + "integrity": "sha512-7dV4eu9gBxoM0dAnj/BCFDW9LFU0zvTrkq0ugM7pnHEgguOEeOz1so2ZghEdzviYzQEED0r4EAgpsBChKy1TRQ==", + "dev": true, + "requires": { + "chalk": "^2.0.0", + "esutils": "^2.0.2", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.5.0.tgz", + "integrity": "sha512-I5nW8AhGpOXGCCNYGc+p7ExQIBxRFnS2fd/d862bNOKvmoEPjYPcfIjsfdy0ujagYOIYPczKgD9l3FsgTkAzKA==", + "dev": true + }, + "@babel/plugin-proposal-async-generator-functions": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.2.0.tgz", + "integrity": "sha512-+Dfo/SCQqrwx48ptLVGLdE39YtWRuKc/Y9I5Fy0P1DDBB9lsAHpjcEJQt+4IifuSOSTLBKJObJqMvaO1pIE8LQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-remap-async-to-generator": "^7.1.0", + "@babel/plugin-syntax-async-generators": "^7.2.0" + } + }, + "@babel/plugin-proposal-dynamic-import": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.5.0.tgz", + "integrity": "sha512-x/iMjggsKTFHYC6g11PL7Qy58IK8H5zqfm9e6hu4z1iH2IRyAp9u9dL80zA6R76yFovETFLKz2VJIC2iIPBuFw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-dynamic-import": "^7.2.0" + } + }, + "@babel/plugin-proposal-json-strings": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.2.0.tgz", + "integrity": "sha512-MAFV1CA/YVmYwZG0fBQyXhmj0BHCB5egZHCKWIFVv/XCxAeVGIHfos3SwDck4LvCllENIAg7xMKOG5kH0dzyUg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-json-strings": "^7.2.0" + } + }, + "@babel/plugin-proposal-object-rest-spread": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.5.4.tgz", + "integrity": "sha512-KCx0z3y7y8ipZUMAEEJOyNi11lMb/FOPUjjB113tfowgw0c16EGYos7worCKBcUAh2oG+OBnoUhsnTSoLpV9uA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-object-rest-spread": "^7.2.0" + } + }, + "@babel/plugin-proposal-optional-catch-binding": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.2.0.tgz", + "integrity": "sha512-mgYj3jCcxug6KUcX4OBoOJz3CMrwRfQELPQ5560F70YQUBZB7uac9fqaWamKR1iWUzGiK2t0ygzjTScZnVz75g==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-optional-catch-binding": "^7.2.0" + } + }, + "@babel/plugin-proposal-unicode-property-regex": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.4.4.tgz", + "integrity": "sha512-j1NwnOqMG9mFUOH58JTFsA/+ZYzQLUZ/drqWUqxCYLGeu2JFZL8YrNC9hBxKmWtAuOCHPcRpgv7fhap09Fb4kA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-regex": "^7.4.4", + "regexpu-core": "^4.5.4" + } + }, + "@babel/plugin-syntax-async-generators": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.2.0.tgz", + "integrity": "sha512-1ZrIRBv2t0GSlcwVoQ6VgSLpLgiN/FVQUzt9znxo7v2Ov4jJrs8RY8tv0wvDmFN3qIdMKWrmMMW6yZ0G19MfGg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-syntax-dynamic-import": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.2.0.tgz", + "integrity": "sha512-mVxuJ0YroI/h/tbFTPGZR8cv6ai+STMKNBq0f8hFxsxWjl94qqhsb+wXbpNMDPU3cfR1TIsVFzU3nXyZMqyK4w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-syntax-json-strings": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.2.0.tgz", + "integrity": "sha512-5UGYnMSLRE1dqqZwug+1LISpA403HzlSfsg6P9VXU6TBjcSHeNlw4DxDx7LgpF+iKZoOG/+uzqoRHTdcUpiZNg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-syntax-object-rest-spread": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.2.0.tgz", + "integrity": "sha512-t0JKGgqk2We+9may3t0xDdmneaXmyxq0xieYcKHxIsrJO64n1OiMWNUtc5gQK1PA0NpdCRrtZp4z+IUaKugrSA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-syntax-optional-catch-binding": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.2.0.tgz", + "integrity": "sha512-bDe4xKNhb0LI7IvZHiA13kff0KEfaGX/Hv4lMA9+7TEc63hMNvfKo6ZFpXhKuEp+II/q35Gc4NoMeDZyaUbj9w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-arrow-functions": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.2.0.tgz", + "integrity": "sha512-ER77Cax1+8/8jCB9fo4Ud161OZzWN5qawi4GusDuRLcDbDG+bIGYY20zb2dfAFdTRGzrfq2xZPvF0R64EHnimg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-async-to-generator": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.5.0.tgz", + "integrity": "sha512-mqvkzwIGkq0bEF1zLRRiTdjfomZJDV33AH3oQzHVGkI2VzEmXLpKKOBvEVaFZBJdN0XTyH38s9j/Kiqr68dggg==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.0.0", + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-remap-async-to-generator": "^7.1.0" + } + }, + "@babel/plugin-transform-block-scoped-functions": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.2.0.tgz", + "integrity": "sha512-ntQPR6q1/NKuphly49+QiQiTN0O63uOwjdD6dhIjSWBI5xlrbUFh720TIpzBhpnrLfv2tNH/BXvLIab1+BAI0w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-block-scoping": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.4.4.tgz", + "integrity": "sha512-jkTUyWZcTrwxu5DD4rWz6rDB5Cjdmgz6z7M7RLXOJyCUkFBawssDGcGh8M/0FTSB87avyJI1HsTwUXp9nKA1PA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "lodash": "^4.17.11" + } + }, + "@babel/plugin-transform-classes": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.4.4.tgz", + "integrity": "sha512-/e44eFLImEGIpL9qPxSRat13I5QNRgBLu2hOQJCF7VLy/otSM/sypV1+XaIw5+502RX/+6YaSAPmldk+nhHDPw==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.0.0", + "@babel/helper-define-map": "^7.4.4", + "@babel/helper-function-name": "^7.1.0", + "@babel/helper-optimise-call-expression": "^7.0.0", + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-replace-supers": "^7.4.4", + "@babel/helper-split-export-declaration": "^7.4.4", + "globals": "^11.1.0" + } + }, + "@babel/plugin-transform-computed-properties": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.2.0.tgz", + "integrity": "sha512-kP/drqTxY6Xt3NNpKiMomfgkNn4o7+vKxK2DDKcBG9sHj51vHqMBGy8wbDS/J4lMxnqs153/T3+DmCEAkC5cpA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-destructuring": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.5.0.tgz", + "integrity": "sha512-YbYgbd3TryYYLGyC7ZR+Tq8H/+bCmwoaxHfJHupom5ECstzbRLTch6gOQbhEY9Z4hiCNHEURgq06ykFv9JZ/QQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-dotall-regex": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.4.4.tgz", + "integrity": "sha512-P05YEhRc2h53lZDjRPk/OektxCVevFzZs2Gfjd545Wde3k+yFDbXORgl2e0xpbq8mLcKJ7Idss4fAg0zORN/zg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-regex": "^7.4.4", + "regexpu-core": "^4.5.4" + } + }, + "@babel/plugin-transform-duplicate-keys": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.5.0.tgz", + "integrity": "sha512-igcziksHizyQPlX9gfSjHkE2wmoCH3evvD2qR5w29/Dk0SMKE/eOI7f1HhBdNhR/zxJDqrgpoDTq5YSLH/XMsQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-exponentiation-operator": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.2.0.tgz", + "integrity": "sha512-umh4hR6N7mu4Elq9GG8TOu9M0bakvlsREEC+ialrQN6ABS4oDQ69qJv1VtR3uxlKMCQMCvzk7vr17RHKcjx68A==", + "dev": true, + "requires": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.1.0", + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-for-of": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.4.4.tgz", + "integrity": "sha512-9T/5Dlr14Z9TIEXLXkt8T1DU7F24cbhwhMNUziN3hB1AXoZcdzPcTiKGRn/6iOymDqtTKWnr/BtRKN9JwbKtdQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-function-name": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.4.4.tgz", + "integrity": "sha512-iU9pv7U+2jC9ANQkKeNF6DrPy4GBa4NWQtl6dHB4Pb3izX2JOEvDTFarlNsBj/63ZEzNNIAMs3Qw4fNCcSOXJA==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.1.0", + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-literals": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.2.0.tgz", + "integrity": "sha512-2ThDhm4lI4oV7fVQ6pNNK+sx+c/GM5/SaML0w/r4ZB7sAneD/piDJtwdKlNckXeyGK7wlwg2E2w33C/Hh+VFCg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-member-expression-literals": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.2.0.tgz", + "integrity": "sha512-HiU3zKkSU6scTidmnFJ0bMX8hz5ixC93b4MHMiYebmk2lUVNGOboPsqQvx5LzooihijUoLR/v7Nc1rbBtnc7FA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-modules-amd": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.5.0.tgz", + "integrity": "sha512-n20UsQMKnWrltocZZm24cRURxQnWIvsABPJlw/fvoy9c6AgHZzoelAIzajDHAQrDpuKFFPPcFGd7ChsYuIUMpg==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.1.0", + "@babel/helper-plugin-utils": "^7.0.0", + "babel-plugin-dynamic-import-node": "^2.3.0" + } + }, + "@babel/plugin-transform-modules-commonjs": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.5.0.tgz", + "integrity": "sha512-xmHq0B+ytyrWJvQTc5OWAC4ii6Dhr0s22STOoydokG51JjWhyYo5mRPXoi+ZmtHQhZZwuXNN+GG5jy5UZZJxIQ==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.4.4", + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-simple-access": "^7.1.0", + "babel-plugin-dynamic-import-node": "^2.3.0" + } + }, + "@babel/plugin-transform-modules-systemjs": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.5.0.tgz", + "integrity": "sha512-Q2m56tyoQWmuNGxEtUyeEkm6qJYFqs4c+XyXH5RAuYxObRNz9Zgj/1g2GMnjYp2EUyEy7YTrxliGCXzecl/vJg==", + "dev": true, + "requires": { + "@babel/helper-hoist-variables": "^7.4.4", + "@babel/helper-plugin-utils": "^7.0.0", + "babel-plugin-dynamic-import-node": "^2.3.0" + } + }, + "@babel/plugin-transform-modules-umd": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.2.0.tgz", + "integrity": "sha512-BV3bw6MyUH1iIsGhXlOK6sXhmSarZjtJ/vMiD9dNmpY8QXFFQTj+6v92pcfy1iqa8DeAfJFwoxcrS/TUZda6sw==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.1.0", + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.4.5.tgz", + "integrity": "sha512-z7+2IsWafTBbjNsOxU/Iv5CvTJlr5w4+HGu1HovKYTtgJ362f7kBcQglkfmlspKKZ3bgrbSGvLfNx++ZJgCWsg==", + "dev": true, + "requires": { + "regexp-tree": "^0.1.6" + } + }, + "@babel/plugin-transform-new-target": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.4.4.tgz", + "integrity": "sha512-r1z3T2DNGQwwe2vPGZMBNjioT2scgWzK9BCnDEh+46z8EEwXBq24uRzd65I7pjtugzPSj921aM15RpESgzsSuA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-object-super": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.2.0.tgz", + "integrity": "sha512-VMyhPYZISFZAqAPVkiYb7dUe2AsVi2/wCT5+wZdsNO31FojQJa9ns40hzZ6U9f50Jlq4w6qwzdBB2uwqZ00ebg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-replace-supers": "^7.1.0" + } + }, + "@babel/plugin-transform-parameters": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.4.4.tgz", + "integrity": "sha512-oMh5DUO1V63nZcu/ZVLQFqiihBGo4OpxJxR1otF50GMeCLiRx5nUdtokd+u9SuVJrvvuIh9OosRFPP4pIPnwmw==", + "dev": true, + "requires": { + "@babel/helper-call-delegate": "^7.4.4", + "@babel/helper-get-function-arity": "^7.0.0", + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-property-literals": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.2.0.tgz", + "integrity": "sha512-9q7Dbk4RhgcLp8ebduOpCbtjh7C0itoLYHXd9ueASKAG/is5PQtMR5VJGka9NKqGhYEGn5ITahd4h9QeBMylWQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-regenerator": { + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.4.5.tgz", + "integrity": "sha512-gBKRh5qAaCWntnd09S8QC7r3auLCqq5DI6O0DlfoyDjslSBVqBibrMdsqO+Uhmx3+BlOmE/Kw1HFxmGbv0N9dA==", + "dev": true, + "requires": { + "regenerator-transform": "^0.14.0" + } + }, + "@babel/plugin-transform-reserved-words": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.2.0.tgz", + "integrity": "sha512-fz43fqW8E1tAB3DKF19/vxbpib1fuyCwSPE418ge5ZxILnBhWyhtPgz8eh1RCGGJlwvksHkyxMxh0eenFi+kFw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-shorthand-properties": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.2.0.tgz", + "integrity": "sha512-QP4eUM83ha9zmYtpbnyjTLAGKQritA5XW/iG9cjtuOI8s1RuL/3V6a3DeSHfKutJQ+ayUfeZJPcnCYEQzaPQqg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-spread": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.2.2.tgz", + "integrity": "sha512-KWfky/58vubwtS0hLqEnrWJjsMGaOeSBn90Ezn5Jeg9Z8KKHmELbP1yGylMlm5N6TPKeY9A2+UaSYLdxahg01w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-sticky-regex": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.2.0.tgz", + "integrity": "sha512-KKYCoGaRAf+ckH8gEL3JHUaFVyNHKe3ASNsZ+AlktgHevvxGigoIttrEJb8iKN03Q7Eazlv1s6cx2B2cQ3Jabw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-regex": "^7.0.0" + } + }, + "@babel/plugin-transform-template-literals": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.4.4.tgz", + "integrity": "sha512-mQrEC4TWkhLN0z8ygIvEL9ZEToPhG5K7KDW3pzGqOfIGZ28Jb0POUkeWcoz8HnHvhFy6dwAT1j8OzqN8s804+g==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.0.0", + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-typeof-symbol": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.2.0.tgz", + "integrity": "sha512-2LNhETWYxiYysBtrBTqL8+La0jIoQQnIScUJc74OYvUGRmkskNY4EzLCnjHBzdmb38wqtTaixpo1NctEcvMDZw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-unicode-regex": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.4.4.tgz", + "integrity": "sha512-il+/XdNw01i93+M9J9u4T7/e/Ue/vWfNZE4IRUQjplu2Mqb/AFTDimkw2tdEdSH50wuQXZAbXSql0UphQke+vA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-regex": "^7.4.4", + "regexpu-core": "^4.5.4" + } + }, + "@babel/preset-env": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.5.4.tgz", + "integrity": "sha512-hFnFnouyRNiH1rL8YkX1ANCNAUVC8Djwdqfev8i1415tnAG+7hlA5zhZ0Q/3Q5gkop4HioIPbCEWAalqcbxRoQ==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.0.0", + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-proposal-async-generator-functions": "^7.2.0", + "@babel/plugin-proposal-dynamic-import": "^7.5.0", + "@babel/plugin-proposal-json-strings": "^7.2.0", + "@babel/plugin-proposal-object-rest-spread": "^7.5.4", + "@babel/plugin-proposal-optional-catch-binding": "^7.2.0", + "@babel/plugin-proposal-unicode-property-regex": "^7.4.4", + "@babel/plugin-syntax-async-generators": "^7.2.0", + "@babel/plugin-syntax-dynamic-import": "^7.2.0", + "@babel/plugin-syntax-json-strings": "^7.2.0", + "@babel/plugin-syntax-object-rest-spread": "^7.2.0", + "@babel/plugin-syntax-optional-catch-binding": "^7.2.0", + "@babel/plugin-transform-arrow-functions": "^7.2.0", + "@babel/plugin-transform-async-to-generator": "^7.5.0", + "@babel/plugin-transform-block-scoped-functions": "^7.2.0", + "@babel/plugin-transform-block-scoping": "^7.4.4", + "@babel/plugin-transform-classes": "^7.4.4", + "@babel/plugin-transform-computed-properties": "^7.2.0", + "@babel/plugin-transform-destructuring": "^7.5.0", + "@babel/plugin-transform-dotall-regex": "^7.4.4", + "@babel/plugin-transform-duplicate-keys": "^7.5.0", + "@babel/plugin-transform-exponentiation-operator": "^7.2.0", + "@babel/plugin-transform-for-of": "^7.4.4", + "@babel/plugin-transform-function-name": "^7.4.4", + "@babel/plugin-transform-literals": "^7.2.0", + "@babel/plugin-transform-member-expression-literals": "^7.2.0", + "@babel/plugin-transform-modules-amd": "^7.5.0", + "@babel/plugin-transform-modules-commonjs": "^7.5.0", + "@babel/plugin-transform-modules-systemjs": "^7.5.0", + "@babel/plugin-transform-modules-umd": "^7.2.0", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.4.5", + "@babel/plugin-transform-new-target": "^7.4.4", + "@babel/plugin-transform-object-super": "^7.2.0", + "@babel/plugin-transform-parameters": "^7.4.4", + "@babel/plugin-transform-property-literals": "^7.2.0", + "@babel/plugin-transform-regenerator": "^7.4.5", + "@babel/plugin-transform-reserved-words": "^7.2.0", + "@babel/plugin-transform-shorthand-properties": "^7.2.0", + "@babel/plugin-transform-spread": "^7.2.0", + "@babel/plugin-transform-sticky-regex": "^7.2.0", + "@babel/plugin-transform-template-literals": "^7.4.4", + "@babel/plugin-transform-typeof-symbol": "^7.2.0", + "@babel/plugin-transform-unicode-regex": "^7.4.4", + "@babel/types": "^7.5.0", + "browserslist": "^4.6.0", + "core-js-compat": "^3.1.1", + "invariant": "^2.2.2", + "js-levenshtein": "^1.1.3", + "semver": "^5.5.0" + } + }, + "@babel/register": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.4.4.tgz", + "integrity": "sha512-sn51H88GRa00+ZoMqCVgOphmswG4b7mhf9VOB0LUBAieykq2GnRFerlN+JQkO/ntT7wz4jaHNSRPg9IdMPEUkA==", + "dev": true, + "requires": { + "core-js": "^3.0.0", + "find-cache-dir": "^2.0.0", + "lodash": "^4.17.11", + "mkdirp": "^0.5.1", + "pirates": "^4.0.0", + "source-map-support": "^0.5.9" + } + }, + "@babel/template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.4.4.tgz", + "integrity": "sha512-CiGzLN9KgAvgZsnivND7rkA+AeJ9JB0ciPOD4U59GKbQP2iQl+olF1l76kJOupqidozfZ32ghwBEJDhnk9MEcw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.4.4", + "@babel/types": "^7.4.4" + } + }, + "@babel/traverse": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.5.0.tgz", + "integrity": "sha512-SnA9aLbyOCcnnbQEGwdfBggnc142h/rbqqsXcaATj2hZcegCl903pUD/lfpsNBlBSuWow/YDfRyJuWi2EPR5cg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/generator": "^7.5.0", + "@babel/helper-function-name": "^7.1.0", + "@babel/helper-split-export-declaration": "^7.4.4", + "@babel/parser": "^7.5.0", + "@babel/types": "^7.5.0", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.11" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "@babel/types": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.5.0.tgz", + "integrity": "sha512-UFpDVqRABKsW01bvw7/wSUe56uy6RXM5+VJibVVAybDGxEW25jdwiFJEf7ASvSaC7sN7rbE/l3cLp2izav+CtQ==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.11", + "to-fast-properties": "^2.0.0" + } + }, + "@nodelib/fs.scandir": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.1.tgz", + "integrity": "sha512-NT/skIZjgotDSiXs0WqYhgcuBKhUMgfekCmCGtkUAiLqZdOnrdjmZr9wRl3ll64J9NF79uZ4fk16Dx0yMc/Xbg==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "2.0.1", + "run-parallel": "^1.1.9" + } + }, + "@nodelib/fs.stat": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.1.tgz", + "integrity": "sha512-+RqhBlLn6YRBGOIoVYthsG0J9dfpO79eJyN7BYBkZJtfqrBwf2KK+rD/M/yjZR6WBmIhAgOV7S60eCgaSWtbFw==", + "dev": true + }, + "@nodelib/fs.walk": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.2.tgz", + "integrity": "sha512-J/DR3+W12uCzAJkw7niXDcqcKBg6+5G5Q/ZpThpGNzAUz70eOR6RV4XnnSN01qHZiVl0eavoxJsBypQoKsV2QQ==", + "dev": true, + "requires": { + "@nodelib/fs.scandir": "2.1.1", + "fastq": "^1.6.0" + } + }, + "@types/events": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz", + "integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==", + "dev": true + }, + "@types/glob": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.1.tgz", + "integrity": "sha512-1Bh06cbWJUHMC97acuD6UMG29nMt0Aqz1vF3guLfG+kHHJhy3AyohZFFxYk2f7Q1SQIrNwvncxAE0N/9s70F2w==", + "dev": true, + "requires": { + "@types/events": "*", + "@types/minimatch": "*", + "@types/node": "*" + } + }, + "@types/minimatch": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", + "integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==", + "dev": true + }, + "@types/node": { + "version": "12.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.6.3.tgz", + "integrity": "sha512-7TEYTQT1/6PP53NftXXabIZDaZfaoBdeBm8Md/i7zsWRoBe0YwOXguyK8vhHs8ehgB/w9U4K/6EWuTyp0W6nIA==", + "dev": true + }, + "abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true + }, + "ajv": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", + "integrity": "sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==", + "dev": true, + "requires": { + "fast-deep-equal": "^2.0.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "amdefine": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", + "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=", + "dev": true + }, + "ansi-colors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz", + "integrity": "sha512-SFKX67auSNoVR38N3L+nvsPjOE0bybKTYbkf5tRvushrAPQ9V75huw0ZxBkKVeRU9kqH3d6HA4xTckbwZ4ixmA==", + "dev": true, + "requires": { + "ansi-wrap": "^0.1.0" + } + }, + "ansi-gray": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-gray/-/ansi-gray-0.1.1.tgz", + "integrity": "sha1-KWLPVOyXksSFEKPetSRDaGHvclE=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "ansi-wrap": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/ansi-wrap/-/ansi-wrap-0.1.0.tgz", + "integrity": "sha1-qCJQ3bABXponyoLoLqYDu/pF768=", + "dev": true + }, + "anymatch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz", + "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==", + "dev": true, + "requires": { + "micromatch": "^3.1.4", + "normalize-path": "^2.1.1" + } + }, + "append-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/append-buffer/-/append-buffer-1.0.2.tgz", + "integrity": "sha1-2CIM9GYIFSXv6lBhTz3mUU36WPE=", + "dev": true, + "requires": { + "buffer-equal": "^1.0.0" + } + }, + "aproba": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", + "dev": true + }, + "archy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", + "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=", + "dev": true + }, + "are-we-there-yet": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz", + "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==", + "dev": true, + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", + "dev": true + }, + "arr-filter": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/arr-filter/-/arr-filter-1.1.2.tgz", + "integrity": "sha1-Q/3d0JHo7xGqTEXZzcGOLf8XEe4=", + "dev": true, + "requires": { + "make-iterator": "^1.0.0" + } + }, + "arr-flatten": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", + "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", + "dev": true + }, + "arr-map": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/arr-map/-/arr-map-2.0.2.tgz", + "integrity": "sha1-Onc0X/wc814qkYJWAfnljy4kysQ=", + "dev": true, + "requires": { + "make-iterator": "^1.0.0" + } + }, + "arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=", + "dev": true + }, + "array-each": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-each/-/array-each-1.0.1.tgz", + "integrity": "sha1-p5SvDAWrF1KEbudTofIRoFugxE8=", + "dev": true + }, + "array-find-index": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", + "integrity": "sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=", + "dev": true + }, + "array-initial": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/array-initial/-/array-initial-1.1.0.tgz", + "integrity": "sha1-L6dLJnOTccOUe9enrcc74zSz15U=", + "dev": true, + "requires": { + "array-slice": "^1.0.0", + "is-number": "^4.0.0" + }, + "dependencies": { + "is-number": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-4.0.0.tgz", + "integrity": "sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==", + "dev": true + } + } + }, + "array-last": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/array-last/-/array-last-1.3.0.tgz", + "integrity": "sha512-eOCut5rXlI6aCOS7Z7kCplKRKyiFQ6dHFBem4PwlwKeNFk2/XxTrhRh5T9PyaEWGy/NHTZWbY+nsZlNFJu9rYg==", + "dev": true, + "requires": { + "is-number": "^4.0.0" + }, + "dependencies": { + "is-number": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-4.0.0.tgz", + "integrity": "sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==", + "dev": true + } + } + }, + "array-slice": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz", + "integrity": "sha512-B1qMD3RBP7O8o0H2KbrXDyB0IccejMF15+87Lvlor12ONPRHP6gTjXMNkt/d3ZuOGbAe66hFmaCfECI24Ufp6w==", + "dev": true + }, + "array-sort": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-sort/-/array-sort-1.0.0.tgz", + "integrity": "sha512-ihLeJkonmdiAsD7vpgN3CRcx2J2S0TiYW+IS/5zHBI7mKUq3ySvBdzzBfD236ubDBQFiiyG3SWCPc+msQ9KoYg==", + "dev": true, + "requires": { + "default-compare": "^1.0.0", + "get-value": "^2.0.6", + "kind-of": "^5.0.2" + }, + "dependencies": { + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + }, + "array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true + }, + "array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", + "dev": true + }, + "asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", + "dev": true, + "requires": { + "safer-buffer": "~2.1.0" + } + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "dev": true + }, + "assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=", + "dev": true + }, + "async-done": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/async-done/-/async-done-1.3.2.tgz", + "integrity": "sha512-uYkTP8dw2og1tu1nmza1n1CMW0qb8gWWlwqMmLb7MhBVs4BXrFziT6HXUd+/RlRA/i4H9AkofYloUbs1fwMqlw==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.2", + "process-nextick-args": "^2.0.0", + "stream-exhaust": "^1.0.1" + } + }, + "async-each": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.3.tgz", + "integrity": "sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==", + "dev": true + }, + "async-foreach": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/async-foreach/-/async-foreach-0.1.3.tgz", + "integrity": "sha1-NhIfhFwFeBct5Bmpfb6x0W7DRUI=", + "dev": true + }, + "async-settle": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-settle/-/async-settle-1.0.0.tgz", + "integrity": "sha1-HQqRS7Aldb7IqPOnTlCA9yssDGs=", + "dev": true, + "requires": { + "async-done": "^1.2.2" + } + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "dev": true + }, + "atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "dev": true + }, + "aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", + "dev": true + }, + "aws4": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz", + "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==", + "dev": true + }, + "babel-plugin-dynamic-import-node": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.0.tgz", + "integrity": "sha512-o6qFkpeQEBxcqt0XYlWzAVxNCSCZdUgcR8IRlhD/8DylxjjO4foPcvTW0GGKa/cVt3rvxZ7o5ippJ+/0nvLhlQ==", + "dev": true, + "requires": { + "object.assign": "^4.1.0" + } + }, + "bach": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/bach/-/bach-1.2.0.tgz", + "integrity": "sha1-Szzpa/JxNPeaG0FKUcFONMO9mIA=", + "dev": true, + "requires": { + "arr-filter": "^1.1.1", + "arr-flatten": "^1.0.1", + "arr-map": "^2.0.0", + "array-each": "^1.0.0", + "array-initial": "^1.0.0", + "array-last": "^1.1.1", + "async-done": "^1.2.2", + "async-settle": "^1.0.0", + "now-and-later": "^2.0.0" + } + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "base": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", + "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==", + "dev": true, + "requires": { + "cache-base": "^1.0.1", + "class-utils": "^0.3.5", + "component-emitter": "^1.2.1", + "define-property": "^1.0.0", + "isobject": "^3.0.1", + "mixin-deep": "^1.2.0", + "pascalcase": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "dev": true, + "requires": { + "tweetnacl": "^0.14.3" + } + }, + "binary-extensions": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz", + "integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==", + "dev": true + }, + "block-stream": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz", + "integrity": "sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo=", + "dev": true, + "requires": { + "inherits": "~2.0.0" + } + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "browserslist": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.6.6.tgz", + "integrity": "sha512-D2Nk3W9JL9Fp/gIcWei8LrERCS+eXu9AM5cfXA8WEZ84lFks+ARnZ0q/R69m2SV3Wjma83QDDPxsNKXUwdIsyA==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30000984", + "electron-to-chromium": "^1.3.191", + "node-releases": "^1.1.25" + } + }, + "buffer-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.0.tgz", + "integrity": "sha1-WWFrSYME1Var1GaWayLu2j7KX74=", + "dev": true + }, + "buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", + "dev": true + }, + "cache-base": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", + "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==", + "dev": true, + "requires": { + "collection-visit": "^1.0.0", + "component-emitter": "^1.2.1", + "get-value": "^2.0.6", + "has-value": "^1.0.0", + "isobject": "^3.0.1", + "set-value": "^2.0.0", + "to-object-path": "^0.3.0", + "union-value": "^1.0.0", + "unset-value": "^1.0.0" + } + }, + "camelcase": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz", + "integrity": "sha1-MvxLn82vhF/N9+c7uXysImHwqwo=", + "dev": true + }, + "camelcase-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz", + "integrity": "sha1-MIvur/3ygRkFHvodkyITyRuPkuc=", + "dev": true, + "requires": { + "camelcase": "^2.0.0", + "map-obj": "^1.0.0" + }, + "dependencies": { + "camelcase": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", + "integrity": "sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=", + "dev": true + } + } + }, + "caniuse-lite": { + "version": "1.0.30000984", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000984.tgz", + "integrity": "sha512-n5tKOjMaZ1fksIpQbjERuqCyfgec/m9pferkFQbLmWtqLUdmt12hNhjSwsmPdqeiG2NkITOQhr1VYIwWSAceiA==", + "dev": true + }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", + "dev": true + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "chokidar": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.6.tgz", + "integrity": "sha512-V2jUo67OKkc6ySiRpJrjlpJKl9kDuG+Xb8VgsGzb+aEouhgS1D0weyPU4lEzdAcsCAvrih2J2BqyXqHWvVLw5g==", + "dev": true, + "requires": { + "anymatch": "^2.0.0", + "async-each": "^1.0.1", + "braces": "^2.3.2", + "fsevents": "^1.2.7", + "glob-parent": "^3.1.0", + "inherits": "^2.0.3", + "is-binary-path": "^1.0.0", + "is-glob": "^4.0.0", + "normalize-path": "^3.0.0", + "path-is-absolute": "^1.0.0", + "readdirp": "^2.2.1", + "upath": "^1.1.1" + }, + "dependencies": { + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + } + } + }, + "class-utils": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", + "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "define-property": "^0.2.5", + "isobject": "^3.0.0", + "static-extend": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "cliui": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", + "integrity": "sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=", + "dev": true, + "requires": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wrap-ansi": "^2.0.0" + } + }, + "clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=", + "dev": true + }, + "clone-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/clone-buffer/-/clone-buffer-1.0.0.tgz", + "integrity": "sha1-4+JbIHrE5wGvch4staFnksrD3Fg=", + "dev": true + }, + "clone-stats": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/clone-stats/-/clone-stats-1.0.0.tgz", + "integrity": "sha1-s3gt/4u1R04Yuba/D9/ngvh3doA=", + "dev": true + }, + "cloneable-readable": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/cloneable-readable/-/cloneable-readable-1.1.3.tgz", + "integrity": "sha512-2EF8zTQOxYq70Y4XKtorQupqF0m49MBz2/yf5Bj+MHjvpG3Hy7sImifnqD6UA+TKYxeSV+u6qqQPawN5UvnpKQ==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "process-nextick-args": "^2.0.0", + "readable-stream": "^2.3.5" + } + }, + "code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", + "dev": true + }, + "collection-map": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/collection-map/-/collection-map-1.0.0.tgz", + "integrity": "sha1-rqDwb40mx4DCt1SUOFVEsiVa8Yw=", + "dev": true, + "requires": { + "arr-map": "^2.0.2", + "for-own": "^1.0.0", + "make-iterator": "^1.0.0" + } + }, + "collection-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", + "integrity": "sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=", + "dev": true, + "requires": { + "map-visit": "^1.0.0", + "object-visit": "^1.0.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", + "dev": true + }, + "component-emitter": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "concat-with-sourcemaps": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/concat-with-sourcemaps/-/concat-with-sourcemaps-1.1.0.tgz", + "integrity": "sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg==", + "dev": true, + "requires": { + "source-map": "^0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", + "dev": true + }, + "convert-source-map": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.6.0.tgz", + "integrity": "sha512-eFu7XigvxdZ1ETfbgPBohgyQ/Z++C0eEhTor0qRwBw9unw+L0/6V8wkSuGgzdThkiS5lSpdptOQPD8Ak40a+7A==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.1" + } + }, + "copy-descriptor": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", + "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=", + "dev": true + }, + "copy-props": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/copy-props/-/copy-props-2.0.4.tgz", + "integrity": "sha512-7cjuUME+p+S3HZlbllgsn2CDwS+5eCCX16qBgNC4jgSTf49qR1VKy/Zhl400m0IQXl/bPGEVqncgUUMjrr4s8A==", + "dev": true, + "requires": { + "each-props": "^1.3.0", + "is-plain-object": "^2.0.1" + } + }, + "core-js": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.1.4.tgz", + "integrity": "sha512-YNZN8lt82XIMLnLirj9MhKDFZHalwzzrL9YLt6eb0T5D0EDl4IQ90IGkua8mHbnxNrkj1d8hbdizMc0Qmg1WnQ==", + "dev": true + }, + "core-js-compat": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.1.4.tgz", + "integrity": "sha512-Z5zbO9f1d0YrJdoaQhphVAnKPimX92D6z8lCGphH89MNRxlL1prI9ExJPqVwP0/kgkQCv8c4GJGT8X16yUncOg==", + "dev": true, + "requires": { + "browserslist": "^4.6.2", + "core-js-pure": "3.1.4", + "semver": "^6.1.1" + }, + "dependencies": { + "semver": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.2.0.tgz", + "integrity": "sha512-jdFC1VdUGT/2Scgbimf7FSx9iJLXoqfglSF+gJeuNWVpiE37OIbc1jywR/GJyFdz3mnkz2/id0L0J/cr0izR5A==", + "dev": true + } + } + }, + "core-js-pure": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.1.4.tgz", + "integrity": "sha512-uJ4Z7iPNwiu1foygbcZYJsJs1jiXrTTCvxfLDXNhI/I+NHbSIEyr548y4fcsCEyWY0XgfAG/qqaunJ1SThHenA==", + "dev": true + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "dev": true + }, + "currently-unhandled": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", + "integrity": "sha1-mI3zP+qxke95mmE2nddsF635V+o=", + "dev": true, + "requires": { + "array-find-index": "^1.0.1" + } + }, + "d": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", + "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", + "dev": true, + "requires": { + "es5-ext": "^0.10.50", + "type": "^1.0.1" + } + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0" + } + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", + "dev": true + }, + "decode-uri-component": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", + "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", + "dev": true + }, + "default-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/default-compare/-/default-compare-1.0.0.tgz", + "integrity": "sha512-QWfXlM0EkAbqOCbD/6HjdwT19j7WCkMyiRhWilc4H9/5h/RzTF9gv5LYh1+CmDV5d1rki6KAWLtQale0xt20eQ==", + "dev": true, + "requires": { + "kind-of": "^5.0.2" + }, + "dependencies": { + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + }, + "default-resolution": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/default-resolution/-/default-resolution-2.0.0.tgz", + "integrity": "sha1-vLgrqnKtebQmp2cy8aga1t8m1oQ=", + "dev": true + }, + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "dev": true, + "requires": { + "object-keys": "^1.0.12" + } + }, + "define-property": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", + "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", + "dev": true, + "requires": { + "is-descriptor": "^1.0.2", + "isobject": "^3.0.1" + }, + "dependencies": { + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "del": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/del/-/del-5.0.0.tgz", + "integrity": "sha512-TfU3nUY0WDIhN18eq+pgpbLY9AfL5RfiE9czKaTSolc6aK7qASXfDErvYgjV1UqCR4sNXDoxO0/idPmhDUt2Sg==", + "dev": true, + "requires": { + "globby": "^10.0.0", + "is-path-cwd": "^2.0.0", + "is-path-in-cwd": "^2.0.0", + "p-map": "^2.0.0", + "rimraf": "^2.6.3" + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "dev": true + }, + "delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", + "dev": true + }, + "detect-file": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", + "integrity": "sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc=", + "dev": true + }, + "dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "requires": { + "path-type": "^4.0.0" + }, + "dependencies": { + "path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true + } + } + }, + "duplexify": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", + "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", + "dev": true, + "requires": { + "end-of-stream": "^1.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.0.0", + "stream-shift": "^1.0.0" + } + }, + "each-props": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/each-props/-/each-props-1.3.2.tgz", + "integrity": "sha512-vV0Hem3zAGkJAyU7JSjixeU66rwdynTAa1vofCrSA5fEln+m67Az9CcnkVD776/fsN/UjIWmBDoNRS6t6G9RfA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.1", + "object.defaults": "^1.1.0" + } + }, + "ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", + "dev": true, + "requires": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "electron-to-chromium": { + "version": "1.3.191", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.191.tgz", + "integrity": "sha512-jasjtY5RUy/TOyiUYM2fb4BDaPZfm6CXRFeJDMfFsXYADGxUN49RBqtgB7EL2RmJXeIRUk9lM1U6A5yk2YJMPQ==", + "dev": true + }, + "end-of-stream": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz", + "integrity": "sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q==", + "dev": true, + "requires": { + "once": "^1.4.0" + } + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "es5-ext": { + "version": "0.10.50", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.50.tgz", + "integrity": "sha512-KMzZTPBkeQV/JcSQhI5/z6d9VWJ3EnQ194USTUwIYZ2ZbpN8+SGXQKt1h68EX44+qt+Fzr8DO17vnxrw7c3agw==", + "dev": true, + "requires": { + "es6-iterator": "~2.0.3", + "es6-symbol": "~3.1.1", + "next-tick": "^1.0.0" + } + }, + "es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=", + "dev": true, + "requires": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "es6-symbol": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.1.tgz", + "integrity": "sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc=", + "dev": true, + "requires": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "es6-weak-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", + "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", + "dev": true, + "requires": { + "d": "1", + "es5-ext": "^0.10.46", + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.1" + } + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "esutils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", + "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=", + "dev": true + }, + "expand-brackets": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", + "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", + "dev": true, + "requires": { + "debug": "^2.3.3", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "posix-character-classes": "^0.1.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "expand-tilde": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", + "integrity": "sha1-l+gBqgUt8CRU3kawK/YhZCzchQI=", + "dev": true, + "requires": { + "homedir-polyfill": "^1.0.1" + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true + }, + "extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=", + "dev": true, + "requires": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "extglob": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", + "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", + "dev": true, + "requires": { + "array-unique": "^0.3.2", + "define-property": "^1.0.0", + "expand-brackets": "^2.1.4", + "extend-shallow": "^2.0.1", + "fragment-cache": "^0.2.1", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", + "dev": true + }, + "fancy-log": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/fancy-log/-/fancy-log-1.3.3.tgz", + "integrity": "sha512-k9oEhlyc0FrVh25qYuSELjr8oxsCoc4/LEZfg2iJJrfEk/tZL9bCoJE47gqAvI2m/AUjluCS4+3I0eTx8n3AEw==", + "dev": true, + "requires": { + "ansi-gray": "^0.1.1", + "color-support": "^1.1.3", + "parse-node-version": "^1.0.0", + "time-stamp": "^1.0.0" + } + }, + "fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", + "dev": true + }, + "fast-glob": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.0.4.tgz", + "integrity": "sha512-wkIbV6qg37xTJwqSsdnIphL1e+LaGz4AIQqr00mIubMaEhv1/HEmJ0uuCGZRNRUkZZmOB5mJKO0ZUTVq+SxMQg==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "^2.0.1", + "@nodelib/fs.walk": "^1.2.1", + "glob-parent": "^5.0.0", + "is-glob": "^4.0.1", + "merge2": "^1.2.3", + "micromatch": "^4.0.2" + }, + "dependencies": { + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "glob-parent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.0.0.tgz", + "integrity": "sha512-Z2RwiujPRGluePM6j699ktJYxmPpJKCfpGA13jz2hmFZC7gKetzrWvg5KN3+OsIFmydGyZ1AVwERCq1w/ZZwRg==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "micromatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz", + "integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==", + "dev": true, + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.0.5" + } + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + } + } + }, + "fast-json-stable-stringify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=", + "dev": true + }, + "fastq": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.6.0.tgz", + "integrity": "sha512-jmxqQ3Z/nXoeyDmWAzF9kH1aGZSis6e/SbfPmJpUnyZ0ogr6iscHQaml4wsEepEWSdtmpy+eVXmCRIMpxaXqOA==", + "dev": true, + "requires": { + "reusify": "^1.0.0" + } + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "find-cache-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz", + "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^2.0.0", + "pkg-dir": "^3.0.0" + } + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "findup-sync": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-3.0.0.tgz", + "integrity": "sha512-YbffarhcicEhOrm4CtrwdKBdCuz576RLdhJDsIfvNtxUuhdRet1qZcsMjqbePtAseKdAnDyM/IyXbu7PRPRLYg==", + "dev": true, + "requires": { + "detect-file": "^1.0.0", + "is-glob": "^4.0.0", + "micromatch": "^3.0.4", + "resolve-dir": "^1.0.1" + } + }, + "fined": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fined/-/fined-1.2.0.tgz", + "integrity": "sha512-ZYDqPLGxDkDhDZBjZBb+oD1+j0rA4E0pXY50eplAAOPg2N/gUBSSk5IM1/QhPfyVo19lJ+CvXpqfvk+b2p/8Ng==", + "dev": true, + "requires": { + "expand-tilde": "^2.0.2", + "is-plain-object": "^2.0.3", + "object.defaults": "^1.1.0", + "object.pick": "^1.2.0", + "parse-filepath": "^1.0.1" + } + }, + "flagged-respawn": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-1.0.1.tgz", + "integrity": "sha512-lNaHNVymajmk0OJMBn8fVUAU1BtDeKIqKoVhk4xAALB57aALg6b4W0MfJ/cUE0g9YBXy5XhSlPIpYIJ7HaY/3Q==", + "dev": true + }, + "flush-write-stream": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.1.1.tgz", + "integrity": "sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "readable-stream": "^2.3.6" + } + }, + "for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=", + "dev": true + }, + "for-own": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz", + "integrity": "sha1-xjMy9BXO3EsE2/5wz4NklMU8tEs=", + "dev": true, + "requires": { + "for-in": "^1.0.1" + } + }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", + "dev": true + }, + "form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + }, + "fragment-cache": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", + "integrity": "sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=", + "dev": true, + "requires": { + "map-cache": "^0.2.2" + } + }, + "fs-mkdirp-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-mkdirp-stream/-/fs-mkdirp-stream-1.0.0.tgz", + "integrity": "sha1-C3gV/DIBxqaeFNuYzgmMFpNSWes=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.11", + "through2": "^2.0.3" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "fsevents": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.9.tgz", + "integrity": "sha512-oeyj2H3EjjonWcFjD5NvZNE9Rqe4UW+nQBU2HNeKw0koVLEFIhtyETyAakeAM3de7Z/SW5kcA+fZUait9EApnw==", + "dev": true, + "optional": true, + "requires": { + "nan": "^2.12.1", + "node-pre-gyp": "^0.12.0" + }, + "dependencies": { + "abbrev": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "ansi-regex": { + "version": "2.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "aproba": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "optional": true + }, + "are-we-there-yet": { + "version": "1.1.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "balanced-match": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "brace-expansion": { + "version": "1.1.11", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "chownr": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "code-point-at": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "optional": true + }, + "concat-map": { + "version": "0.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "console-control-strings": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "optional": true + }, + "core-util-is": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "debug": { + "version": "4.1.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ms": "^2.1.1" + } + }, + "deep-extend": { + "version": "0.6.0", + "bundled": true, + "dev": true, + "optional": true + }, + "delegates": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "detect-libc": { + "version": "1.0.3", + "bundled": true, + "dev": true, + "optional": true + }, + "fs-minipass": { + "version": "1.2.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.2.1" + } + }, + "fs.realpath": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "gauge": { + "version": "2.7.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "glob": { + "version": "7.1.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "has-unicode": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "iconv-lite": { + "version": "0.4.24", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ignore-walk": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minimatch": "^3.0.4" + } + }, + "inflight": { + "version": "1.0.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "bundled": true, + "dev": true, + "optional": true + }, + "ini": { + "version": "1.3.5", + "bundled": true, + "dev": true, + "optional": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "isarray": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "minimatch": { + "version": "3.0.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "bundled": true, + "dev": true, + "optional": true + }, + "minipass": { + "version": "2.3.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + }, + "minizlib": { + "version": "1.2.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.2.1" + } + }, + "mkdirp": { + "version": "0.5.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "needle": { + "version": "2.3.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "debug": "^4.1.0", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + } + }, + "node-pre-gyp": { + "version": "0.12.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "detect-libc": "^1.0.2", + "mkdirp": "^0.5.1", + "needle": "^2.2.1", + "nopt": "^4.0.1", + "npm-packlist": "^1.1.6", + "npmlog": "^4.0.2", + "rc": "^1.2.7", + "rimraf": "^2.6.1", + "semver": "^5.3.0", + "tar": "^4" + } + }, + "nopt": { + "version": "4.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "abbrev": "1", + "osenv": "^0.1.4" + } + }, + "npm-bundled": { + "version": "1.0.6", + "bundled": true, + "dev": true, + "optional": true + }, + "npm-packlist": { + "version": "1.4.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ignore-walk": "^3.0.1", + "npm-bundled": "^1.0.1" + } + }, + "npmlog": { + "version": "4.1.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "object-assign": { + "version": "4.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "once": { + "version": "1.4.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "wrappy": "1" + } + }, + "os-homedir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "os-tmpdir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "osenv": { + "version": "0.1.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "process-nextick-args": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "rc": { + "version": "1.2.8", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "optional": true + } + } + }, + "readable-stream": { + "version": "2.3.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "rimraf": { + "version": "2.6.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "glob": "^7.1.3" + } + }, + "safe-buffer": { + "version": "5.1.2", + "bundled": true, + "dev": true, + "optional": true + }, + "safer-buffer": { + "version": "2.1.2", + "bundled": true, + "dev": true, + "optional": true + }, + "sax": { + "version": "1.2.4", + "bundled": true, + "dev": true, + "optional": true + }, + "semver": { + "version": "5.7.0", + "bundled": true, + "dev": true, + "optional": true + }, + "set-blocking": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "signal-exit": { + "version": "3.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "string-width": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "string_decoder": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "tar": { + "version": "4.4.8", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "chownr": "^1.1.1", + "fs-minipass": "^1.2.5", + "minipass": "^2.3.4", + "minizlib": "^1.1.1", + "mkdirp": "^0.5.0", + "safe-buffer": "^5.1.2", + "yallist": "^3.0.2" + } + }, + "util-deprecate": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "wide-align": { + "version": "1.1.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "string-width": "^1.0.2 || 2" + } + }, + "wrappy": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "yallist": { + "version": "3.0.3", + "bundled": true, + "dev": true, + "optional": true + } + } + }, + "fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + } + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "gauge": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", + "dev": true, + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + } + } + }, + "gaze": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/gaze/-/gaze-1.1.3.tgz", + "integrity": "sha512-BRdNm8hbWzFzWHERTrejLqwHDfS4GibPoq5wjTPIoJHoBtKGPg3xAFfxmM+9ztbXelxcf2hwQcaz1PtmFeue8g==", + "dev": true, + "requires": { + "globule": "^1.0.0" + } + }, + "get-caller-file": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", + "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==", + "dev": true + }, + "get-stdin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz", + "integrity": "sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=", + "dev": true + }, + "get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=", + "dev": true + }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0" + } + }, + "glob": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", + "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", + "integrity": "sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=", + "dev": true, + "requires": { + "is-glob": "^3.1.0", + "path-dirname": "^1.0.0" + }, + "dependencies": { + "is-glob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", + "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", + "dev": true, + "requires": { + "is-extglob": "^2.1.0" + } + } + } + }, + "glob-stream": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/glob-stream/-/glob-stream-6.1.0.tgz", + "integrity": "sha1-cEXJlBOz65SIjYOrRtC0BMx73eQ=", + "dev": true, + "requires": { + "extend": "^3.0.0", + "glob": "^7.1.1", + "glob-parent": "^3.1.0", + "is-negated-glob": "^1.0.0", + "ordered-read-streams": "^1.0.0", + "pumpify": "^1.3.5", + "readable-stream": "^2.1.5", + "remove-trailing-separator": "^1.0.1", + "to-absolute-glob": "^2.0.0", + "unique-stream": "^2.0.2" + } + }, + "glob-watcher": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/glob-watcher/-/glob-watcher-5.0.3.tgz", + "integrity": "sha512-8tWsULNEPHKQ2MR4zXuzSmqbdyV5PtwwCaWSGQ1WwHsJ07ilNeN1JB8ntxhckbnpSHaf9dXFUHzIWvm1I13dsg==", + "dev": true, + "requires": { + "anymatch": "^2.0.0", + "async-done": "^1.2.0", + "chokidar": "^2.0.0", + "is-negated-glob": "^1.0.0", + "just-debounce": "^1.0.0", + "object.defaults": "^1.1.0" + } + }, + "global-modules": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", + "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==", + "dev": true, + "requires": { + "global-prefix": "^1.0.1", + "is-windows": "^1.0.1", + "resolve-dir": "^1.0.0" + } + }, + "global-prefix": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz", + "integrity": "sha1-2/dDxsFJklk8ZVVoy2btMsASLr4=", + "dev": true, + "requires": { + "expand-tilde": "^2.0.2", + "homedir-polyfill": "^1.0.1", + "ini": "^1.3.4", + "is-windows": "^1.0.1", + "which": "^1.2.14" + } + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "globby": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/globby/-/globby-10.0.1.tgz", + "integrity": "sha512-sSs4inE1FB2YQiymcmTv6NWENryABjUNPeWhOvmn4SjtKybglsyPZxFB3U1/+L1bYi0rNZDqCLlHyLYDl1Pq5A==", + "dev": true, + "requires": { + "@types/glob": "^7.1.1", + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.0.3", + "glob": "^7.1.3", + "ignore": "^5.1.1", + "merge2": "^1.2.3", + "slash": "^3.0.0" + } + }, + "globule": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/globule/-/globule-1.2.1.tgz", + "integrity": "sha512-g7QtgWF4uYSL5/dn71WxubOrS7JVGCnFPEnoeChJmBnyR9Mw8nGoEwOgJL/RC2Te0WhbsEUCejfH8SZNJ+adYQ==", + "dev": true, + "requires": { + "glob": "~7.1.1", + "lodash": "~4.17.10", + "minimatch": "~3.0.2" + } + }, + "glogg": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/glogg/-/glogg-1.0.2.tgz", + "integrity": "sha512-5mwUoSuBk44Y4EshyiqcH95ZntbDdTQqA3QYSrxmzj28Ai0vXBGMH1ApSANH14j2sIRtqCEyg6PfsuP7ElOEDA==", + "dev": true, + "requires": { + "sparkles": "^1.0.0" + } + }, + "graceful-fs": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.0.tgz", + "integrity": "sha512-jpSvDPV4Cq/bgtpndIWbI5hmYxhQGHPC4d4cqBPb4DLniCfhJokdXhwhaDuLBGLQdvvRum/UiX6ECVIPvDXqdg==", + "dev": true + }, + "gulp": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/gulp/-/gulp-4.0.2.tgz", + "integrity": "sha512-dvEs27SCZt2ibF29xYgmnwwCYZxdxhQ/+LFWlbAW8y7jt68L/65402Lz3+CKy0Ov4rOs+NERmDq7YlZaDqUIfA==", + "dev": true, + "requires": { + "glob-watcher": "^5.0.3", + "gulp-cli": "^2.2.0", + "undertaker": "^1.2.1", + "vinyl-fs": "^3.0.0" + } + }, + "gulp-babel": { + "version": "8.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gulp-babel/-/gulp-babel-8.0.0-beta.2.tgz", + "integrity": "sha512-GTC2PxAXWkp6u1fP+C5+kn5biQ0dKGhkOSSXvKAf3ykF0+R3tevmLm/zSIkc1+S7U1JwH3XTvuMwRL6LD+sEiw==", + "dev": true, + "requires": { + "plugin-error": "^1.0.1", + "replace-ext": "^1.0.0", + "through2": "^2.0.0", + "vinyl-sourcemaps-apply": "^0.2.0" + } + }, + "gulp-cli": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/gulp-cli/-/gulp-cli-2.2.0.tgz", + "integrity": "sha512-rGs3bVYHdyJpLqR0TUBnlcZ1O5O++Zs4bA0ajm+zr3WFCfiSLjGwoCBqFs18wzN+ZxahT9DkOK5nDf26iDsWjA==", + "dev": true, + "requires": { + "ansi-colors": "^1.0.1", + "archy": "^1.0.0", + "array-sort": "^1.0.0", + "color-support": "^1.1.3", + "concat-stream": "^1.6.0", + "copy-props": "^2.0.1", + "fancy-log": "^1.3.2", + "gulplog": "^1.0.0", + "interpret": "^1.1.0", + "isobject": "^3.0.1", + "liftoff": "^3.1.0", + "matchdep": "^2.0.0", + "mute-stdout": "^1.0.0", + "pretty-hrtime": "^1.0.0", + "replace-homedir": "^1.0.0", + "semver-greatest-satisfied-range": "^1.1.0", + "v8flags": "^3.0.1", + "yargs": "^7.1.0" + } + }, + "gulp-concat": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/gulp-concat/-/gulp-concat-2.6.1.tgz", + "integrity": "sha1-Yz0WyV2IUEYorQJmVmPO5aR5M1M=", + "dev": true, + "requires": { + "concat-with-sourcemaps": "^1.0.0", + "through2": "^2.0.0", + "vinyl": "^2.0.0" + } + }, + "gulp-sass": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/gulp-sass/-/gulp-sass-4.0.2.tgz", + "integrity": "sha512-q8psj4+aDrblJMMtRxihNBdovfzGrXJp1l4JU0Sz4b/Mhsi2DPrKFYCGDwjIWRENs04ELVHxdOJQ7Vs98OFohg==", + "dev": true, + "requires": { + "chalk": "^2.3.0", + "lodash.clonedeep": "^4.3.2", + "node-sass": "^4.8.3", + "plugin-error": "^1.0.1", + "replace-ext": "^1.0.0", + "strip-ansi": "^4.0.0", + "through2": "^2.0.0", + "vinyl-sourcemaps-apply": "^0.2.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "gulplog": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/gulplog/-/gulplog-1.0.0.tgz", + "integrity": "sha1-4oxNRdBey77YGDY86PnFkmIp/+U=", + "dev": true, + "requires": { + "glogg": "^1.0.0" + } + }, + "har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", + "dev": true + }, + "har-validator": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz", + "integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==", + "dev": true, + "requires": { + "ajv": "^6.5.5", + "har-schema": "^2.0.0" + } + }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + } + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "has-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.0.tgz", + "integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=", + "dev": true + }, + "has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", + "dev": true + }, + "has-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", + "integrity": "sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=", + "dev": true, + "requires": { + "get-value": "^2.0.6", + "has-values": "^1.0.0", + "isobject": "^3.0.0" + } + }, + "has-values": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz", + "integrity": "sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "kind-of": "^4.0.0" + }, + "dependencies": { + "kind-of": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", + "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "homedir-polyfill": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", + "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==", + "dev": true, + "requires": { + "parse-passwd": "^1.0.0" + } + }, + "hosted-git-info": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.7.1.tgz", + "integrity": "sha512-7T/BxH19zbcCTa8XkMlbK5lTo1WtgkFi3GvdWEyNuc4Vex7/9Dqbnpsf4JMydcfj9HCg4zUWFTL3Za6lapg5/w==", + "dev": true + }, + "http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, + "ignore": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.2.tgz", + "integrity": "sha512-vdqWBp7MyzdmHkkRWV5nY+PfGRbYbahfuvsBCh277tq+w9zyNi7h5CYJCK0kmzti9kU+O/cB7sE8HvKv6aXAKQ==", + "dev": true + }, + "in-publish": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/in-publish/-/in-publish-2.0.0.tgz", + "integrity": "sha1-4g/146KvwmkDILbcVSaCqcf631E=", + "dev": true + }, + "indent-string": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz", + "integrity": "sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=", + "dev": true, + "requires": { + "repeating": "^2.0.0" + } + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "ini": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", + "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", + "dev": true + }, + "interpret": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.2.0.tgz", + "integrity": "sha512-mT34yGKMNceBQUoVn7iCDKDntA7SC6gycMAWzGx1z/CMCTV7b2AAtXlo3nRyHZ1FelRkQbQjprHSYGwzLtkVbw==", + "dev": true + }, + "invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dev": true, + "requires": { + "loose-envify": "^1.0.0" + } + }, + "invert-kv": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", + "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=", + "dev": true + }, + "is-absolute": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", + "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==", + "dev": true, + "requires": { + "is-relative": "^1.0.0", + "is-windows": "^1.0.1" + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + }, + "is-binary-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz", + "integrity": "sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=", + "dev": true, + "requires": { + "binary-extensions": "^1.0.0" + } + }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^0.1.6", + "is-data-descriptor": "^0.1.4", + "kind-of": "^5.0.0" + }, + "dependencies": { + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + }, + "is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-finite": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.2.tgz", + "integrity": "sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "is-glob": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", + "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-negated-glob": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-negated-glob/-/is-negated-glob-1.0.0.tgz", + "integrity": "sha1-aRC8pdqMleeEtXUbl2z1oQ/uNtI=", + "dev": true + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-path-cwd": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", + "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", + "dev": true + }, + "is-path-in-cwd": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-2.1.0.tgz", + "integrity": "sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ==", + "dev": true, + "requires": { + "is-path-inside": "^2.1.0" + } + }, + "is-path-inside": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-2.1.0.tgz", + "integrity": "sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg==", + "dev": true, + "requires": { + "path-is-inside": "^1.0.2" + } + }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "is-relative": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", + "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==", + "dev": true, + "requires": { + "is-unc-path": "^1.0.0" + } + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", + "dev": true + }, + "is-unc-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", + "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==", + "dev": true, + "requires": { + "unc-path-regex": "^0.1.2" + } + }, + "is-utf8": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", + "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=", + "dev": true + }, + "is-valid-glob": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-valid-glob/-/is-valid-glob-1.0.0.tgz", + "integrity": "sha1-Kb8+/3Ab4tTTFdusw5vDn+j2Aao=", + "dev": true + }, + "is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", + "dev": true + }, + "js-base64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.5.1.tgz", + "integrity": "sha512-M7kLczedRMYX4L8Mdh4MzyAMM9O5osx+4FcOQuTvr3A9F2D9S5JXheN0ewNbrvK2UatkTRhL5ejGmGSjNMiZuw==", + "dev": true + }, + "js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", + "dev": true + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true + }, + "json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", + "dev": true + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", + "dev": true + }, + "json5": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.0.tgz", + "integrity": "sha512-8Mh9h6xViijj36g7Dxi+Y4S6hNGV96vcJZr/SrlHh1LR/pEn/8j/+qIBbs44YKl69Lrfctp4QD+AdWLTMqEZAQ==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "jsprim": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", + "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "dev": true, + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + } + }, + "just-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/just-debounce/-/just-debounce-1.0.0.tgz", + "integrity": "sha1-h/zPrv/AtozRnVX2cilD+SnqNeo=", + "dev": true + }, + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "dev": true + }, + "last-run": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/last-run/-/last-run-1.1.1.tgz", + "integrity": "sha1-RblpQsF7HHnHchmCWbqUO+v4yls=", + "dev": true, + "requires": { + "default-resolution": "^2.0.0", + "es6-weak-map": "^2.0.1" + } + }, + "lazystream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.0.tgz", + "integrity": "sha1-9plf4PggOS9hOWvolGJAe7dxaOQ=", + "dev": true, + "requires": { + "readable-stream": "^2.0.5" + } + }, + "lcid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", + "integrity": "sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU=", + "dev": true, + "requires": { + "invert-kv": "^1.0.0" + } + }, + "lead": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lead/-/lead-1.0.0.tgz", + "integrity": "sha1-bxT5mje+Op3XhPVJVpDlkDRm7kI=", + "dev": true, + "requires": { + "flush-write-stream": "^1.0.2" + } + }, + "liftoff": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-3.1.0.tgz", + "integrity": "sha512-DlIPlJUkCV0Ips2zf2pJP0unEoT1kwYhiiPUGF3s/jtxTCjziNLoiVVh+jqWOWeFi6mmwQ5fNxvAUyPad4Dfog==", + "dev": true, + "requires": { + "extend": "^3.0.0", + "findup-sync": "^3.0.0", + "fined": "^1.0.1", + "flagged-respawn": "^1.0.0", + "is-plain-object": "^2.0.4", + "object.map": "^1.0.0", + "rechoir": "^0.6.2", + "resolve": "^1.1.7" + } + }, + "load-json-file": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", + "integrity": "sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0", + "strip-bom": "^2.0.0" + }, + "dependencies": { + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + } + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "lodash": { + "version": "4.17.14", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.14.tgz", + "integrity": "sha512-mmKYbW3GLuJeX+iGP+Y7Gp1AiGHGbXHCOh/jZmrawMmsE7MS4znI3RL2FsjbqOyMayHInjOeykW7PEajUk1/xw==", + "dev": true + }, + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", + "dev": true + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "loud-rejection": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz", + "integrity": "sha1-W0b4AUft7leIcPCG0Eghz5mOVR8=", + "dev": true, + "requires": { + "currently-unhandled": "^0.4.1", + "signal-exit": "^3.0.0" + } + }, + "make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, + "requires": { + "pify": "^4.0.1", + "semver": "^5.6.0" + } + }, + "make-iterator": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/make-iterator/-/make-iterator-1.0.1.tgz", + "integrity": "sha512-pxiuXh0iVEq7VM7KMIhs5gxsfxCux2URptUQaXo4iZZJxBAzTPOLE2BumO5dbfVYq/hBJFBR/a1mFDmOx5AGmw==", + "dev": true, + "requires": { + "kind-of": "^6.0.2" + } + }, + "map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=", + "dev": true + }, + "map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=", + "dev": true + }, + "map-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", + "integrity": "sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=", + "dev": true, + "requires": { + "object-visit": "^1.0.0" + } + }, + "matchdep": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/matchdep/-/matchdep-2.0.0.tgz", + "integrity": "sha1-xvNINKDY28OzfCfui7yyfHd1WC4=", + "dev": true, + "requires": { + "findup-sync": "^2.0.0", + "micromatch": "^3.0.4", + "resolve": "^1.4.0", + "stack-trace": "0.0.10" + }, + "dependencies": { + "findup-sync": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-2.0.0.tgz", + "integrity": "sha1-kyaxSIwi0aYIhlCoaQGy2akKLLw=", + "dev": true, + "requires": { + "detect-file": "^1.0.0", + "is-glob": "^3.1.0", + "micromatch": "^3.0.4", + "resolve-dir": "^1.0.1" + } + }, + "is-glob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", + "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", + "dev": true, + "requires": { + "is-extglob": "^2.1.0" + } + } + } + }, + "meow": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz", + "integrity": "sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=", + "dev": true, + "requires": { + "camelcase-keys": "^2.0.0", + "decamelize": "^1.1.2", + "loud-rejection": "^1.0.0", + "map-obj": "^1.0.1", + "minimist": "^1.1.3", + "normalize-package-data": "^2.3.4", + "object-assign": "^4.0.1", + "read-pkg-up": "^1.0.1", + "redent": "^1.0.0", + "trim-newlines": "^1.0.0" + } + }, + "merge2": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.2.3.tgz", + "integrity": "sha512-gdUU1Fwj5ep4kplwcmftruWofEFt6lfpkkr3h860CXbAB9c3hGb55EOL2ali0Td5oebvW0E1+3Sr+Ur7XfKpRA==", + "dev": true + }, + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + }, + "mime-db": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz", + "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==", + "dev": true + }, + "mime-types": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.24.tgz", + "integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==", + "dev": true, + "requires": { + "mime-db": "1.40.0" + } + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true + }, + "mixin-deep": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", + "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", + "dev": true, + "requires": { + "for-in": "^1.0.2", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + }, + "dependencies": { + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + } + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "mute-stdout": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mute-stdout/-/mute-stdout-1.0.1.tgz", + "integrity": "sha512-kDcwXR4PS7caBpuRYYBUz9iVixUk3anO3f5OYFiIPwK/20vCzKCHyKoulbiDY1S53zD2bxUpxN/IJ+TnXjfvxg==", + "dev": true + }, + "nan": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz", + "integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==", + "dev": true + }, + "nanomatch": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", + "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "fragment-cache": "^0.2.1", + "is-windows": "^1.0.2", + "kind-of": "^6.0.2", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + } + }, + "next-tick": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", + "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=", + "dev": true + }, + "node-gyp": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-3.8.0.tgz", + "integrity": "sha512-3g8lYefrRRzvGeSowdJKAKyks8oUpLEd/DyPV4eMhVlhJ0aNaZqIrNUIPuEWWTAoPqyFkfGrM67MC69baqn6vA==", + "dev": true, + "requires": { + "fstream": "^1.0.0", + "glob": "^7.0.3", + "graceful-fs": "^4.1.2", + "mkdirp": "^0.5.0", + "nopt": "2 || 3", + "npmlog": "0 || 1 || 2 || 3 || 4", + "osenv": "0", + "request": "^2.87.0", + "rimraf": "2", + "semver": "~5.3.0", + "tar": "^2.0.0", + "which": "1" + }, + "dependencies": { + "semver": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz", + "integrity": "sha1-myzl094C0XxgEq0yaqa00M9U+U8=", + "dev": true + } + } + }, + "node-modules-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz", + "integrity": "sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA=", + "dev": true + }, + "node-releases": { + "version": "1.1.25", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.25.tgz", + "integrity": "sha512-fI5BXuk83lKEoZDdH3gRhtsNgh05/wZacuXkgbiYkceE7+QIMXOg98n9ZV7mz27B+kFHnqHcUpscZZlGRSmTpQ==", + "dev": true, + "requires": { + "semver": "^5.3.0" + } + }, + "node-sass": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/node-sass/-/node-sass-4.12.0.tgz", + "integrity": "sha512-A1Iv4oN+Iel6EPv77/HddXErL2a+gZ4uBeZUy+a8O35CFYTXhgA8MgLCWBtwpGZdCvTvQ9d+bQxX/QC36GDPpQ==", + "dev": true, + "requires": { + "async-foreach": "^0.1.3", + "chalk": "^1.1.1", + "cross-spawn": "^3.0.0", + "gaze": "^1.0.0", + "get-stdin": "^4.0.1", + "glob": "^7.0.3", + "in-publish": "^2.0.0", + "lodash": "^4.17.11", + "meow": "^3.7.0", + "mkdirp": "^0.5.1", + "nan": "^2.13.2", + "node-gyp": "^3.8.0", + "npmlog": "^4.0.0", + "request": "^2.88.0", + "sass-graph": "^2.2.4", + "stdout-stream": "^1.4.0", + "true-case-path": "^1.0.2" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "cross-spawn": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-3.0.1.tgz", + "integrity": "sha1-ElYDfsufDF9549bvE14wdwGEuYI=", + "dev": true, + "requires": { + "lru-cache": "^4.0.1", + "which": "^1.2.9" + } + }, + "lru-cache": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", + "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "dev": true, + "requires": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + }, + "yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", + "dev": true + } + } + }, + "nopt": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", + "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=", + "dev": true, + "requires": { + "abbrev": "1" + } + }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", + "dev": true, + "requires": { + "remove-trailing-separator": "^1.0.1" + } + }, + "now-and-later": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/now-and-later/-/now-and-later-2.0.1.tgz", + "integrity": "sha512-KGvQ0cB70AQfg107Xvs/Fbu+dGmZoTRJp2TaPwcwQm3/7PteUyN2BCgk8KBMPGBUXZdVwyWS8fDCGFygBm19UQ==", + "dev": true, + "requires": { + "once": "^1.3.2" + } + }, + "npmlog": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", + "dev": true, + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", + "dev": true + }, + "oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "dev": true + }, + "object-copy": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", + "integrity": "sha1-fn2Fi3gb18mRpBupde04EnVOmYw=", + "dev": true, + "requires": { + "copy-descriptor": "^0.1.0", + "define-property": "^0.2.5", + "kind-of": "^3.0.3" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + }, + "object-visit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", + "integrity": "sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=", + "dev": true, + "requires": { + "isobject": "^3.0.0" + } + }, + "object.assign": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz", + "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "function-bind": "^1.1.1", + "has-symbols": "^1.0.0", + "object-keys": "^1.0.11" + } + }, + "object.defaults": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/object.defaults/-/object.defaults-1.1.0.tgz", + "integrity": "sha1-On+GgzS0B96gbaFtiNXNKeQ1/s8=", + "dev": true, + "requires": { + "array-each": "^1.0.1", + "array-slice": "^1.0.0", + "for-own": "^1.0.0", + "isobject": "^3.0.0" + } + }, + "object.map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object.map/-/object.map-1.0.1.tgz", + "integrity": "sha1-z4Plncj8wK1fQlDh94s7gb2AHTc=", + "dev": true, + "requires": { + "for-own": "^1.0.0", + "make-iterator": "^1.0.0" + } + }, + "object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "object.reduce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object.reduce/-/object.reduce-1.0.1.tgz", + "integrity": "sha1-b+NI8qx/oPlcpiEiZZkJaCW7A60=", + "dev": true, + "requires": { + "for-own": "^1.0.0", + "make-iterator": "^1.0.0" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "ordered-read-streams": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ordered-read-streams/-/ordered-read-streams-1.0.1.tgz", + "integrity": "sha1-d8DLN8QVJdZBZtmQ/61+xqDhNj4=", + "dev": true, + "requires": { + "readable-stream": "^2.0.1" + } + }, + "os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", + "dev": true + }, + "os-locale": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", + "integrity": "sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=", + "dev": true, + "requires": { + "lcid": "^1.0.0" + } + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "dev": true + }, + "osenv": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", + "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", + "dev": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "p-limit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.0.tgz", + "integrity": "sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "parse-filepath": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz", + "integrity": "sha1-pjISf1Oq89FYdvWHLz/6x2PWyJE=", + "dev": true, + "requires": { + "is-absolute": "^1.0.0", + "map-cache": "^0.2.0", + "path-root": "^0.1.1" + } + }, + "parse-json": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", + "integrity": "sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=", + "dev": true, + "requires": { + "error-ex": "^1.2.0" + } + }, + "parse-node-version": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", + "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", + "dev": true + }, + "parse-passwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", + "integrity": "sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=", + "dev": true + }, + "pascalcase": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", + "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=", + "dev": true + }, + "path-dirname": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", + "integrity": "sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=", + "dev": true + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=", + "dev": true + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "dev": true + }, + "path-root": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz", + "integrity": "sha1-mkpoFMrBwM1zNgqV8yCDyOpHRbc=", + "dev": true, + "requires": { + "path-root-regex": "^0.1.0" + } + }, + "path-root-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz", + "integrity": "sha1-v8zcjfWxLcUsi0PsONGNcsBLqW0=", + "dev": true + }, + "path-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz", + "integrity": "sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + }, + "dependencies": { + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + } + } + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", + "dev": true + }, + "picomatch": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.0.7.tgz", + "integrity": "sha512-oLHIdio3tZ0qH76NybpeneBhYVj0QFTfXEFTc/B3zKQspYfYYkWYgFsmzo+4kvId/bQRcNkVeguI3y+CD22BtA==", + "dev": true + }, + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true + }, + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=", + "dev": true + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "dev": true, + "requires": { + "pinkie": "^2.0.0" + } + }, + "pirates": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.1.tgz", + "integrity": "sha512-WuNqLTbMI3tmfef2TKxlQmAiLHKtFhlsCZnPIpuv2Ow0RDVO8lfy1Opf4NUzlMXLjPl+Men7AuVdX6TA+s+uGA==", + "dev": true, + "requires": { + "node-modules-regexp": "^1.0.0" + } + }, + "pkg-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", + "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", + "dev": true, + "requires": { + "find-up": "^3.0.0" + } + }, + "plugin-error": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/plugin-error/-/plugin-error-1.0.1.tgz", + "integrity": "sha512-L1zP0dk7vGweZME2i+EeakvUNqSrdiI3F91TwEoYiGrAfUXmVv6fJIq4g82PAXxNsWOp0J7ZqQy/3Szz0ajTxA==", + "dev": true, + "requires": { + "ansi-colors": "^1.0.1", + "arr-diff": "^4.0.0", + "arr-union": "^3.1.0", + "extend-shallow": "^3.0.2" + } + }, + "posix-character-classes": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", + "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=", + "dev": true + }, + "prettier": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.18.2.tgz", + "integrity": "sha512-OeHeMc0JhFE9idD4ZdtNibzY0+TPHSpSSb9h8FqtP+YnoZZ1sl8Vc9b1sasjfymH3SonAF4QcA2+mzHPhMvIiw==", + "dev": true + }, + "pretty-hrtime": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", + "integrity": "sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=", + "dev": true + }, + "private": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/private/-/private-0.1.8.tgz", + "integrity": "sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==", + "dev": true + }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, + "pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=", + "dev": true + }, + "psl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.2.0.tgz", + "integrity": "sha512-GEn74ZffufCmkDDLNcl3uuyF/aSD6exEyh1v/ZSdAomB82t6G9hzJVRx0jBmLDW+VfZqks3aScmMw9DszwUalA==", + "dev": true + }, + "pump": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz", + "integrity": "sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "pumpify": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/pumpify/-/pumpify-1.5.1.tgz", + "integrity": "sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==", + "dev": true, + "requires": { + "duplexify": "^3.6.0", + "inherits": "^2.0.3", + "pump": "^2.0.0" + } + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true + }, + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", + "dev": true + }, + "read-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", + "integrity": "sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=", + "dev": true, + "requires": { + "load-json-file": "^1.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^1.0.0" + } + }, + "read-pkg-up": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz", + "integrity": "sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=", + "dev": true, + "requires": { + "find-up": "^1.0.0", + "read-pkg": "^1.0.0" + }, + "dependencies": { + "find-up": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", + "integrity": "sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=", + "dev": true, + "requires": { + "path-exists": "^2.0.0", + "pinkie-promise": "^2.0.0" + } + }, + "path-exists": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz", + "integrity": "sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=", + "dev": true, + "requires": { + "pinkie-promise": "^2.0.0" + } + } + } + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "readdirp": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz", + "integrity": "sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.11", + "micromatch": "^3.1.10", + "readable-stream": "^2.0.2" + } + }, + "rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=", + "dev": true, + "requires": { + "resolve": "^1.1.6" + } + }, + "redent": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz", + "integrity": "sha1-z5Fqsf1fHxbfsggi3W7H9zDCr94=", + "dev": true, + "requires": { + "indent-string": "^2.1.0", + "strip-indent": "^1.0.1" + } + }, + "regenerate": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.0.tgz", + "integrity": "sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg==", + "dev": true + }, + "regenerate-unicode-properties": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-8.1.0.tgz", + "integrity": "sha512-LGZzkgtLY79GeXLm8Dp0BVLdQlWICzBnJz/ipWUgo59qBaZ+BHtq51P2q1uVZlppMuUAT37SDk39qUbjTWB7bA==", + "dev": true, + "requires": { + "regenerate": "^1.4.0" + } + }, + "regenerator-transform": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.14.0.tgz", + "integrity": "sha512-rtOelq4Cawlbmq9xuMR5gdFmv7ku/sFoB7sRiywx7aq53bc52b4j6zvH7Te1Vt/X2YveDKnCGUbioieU7FEL3w==", + "dev": true, + "requires": { + "private": "^0.1.6" + } + }, + "regex-not": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", + "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==", + "dev": true, + "requires": { + "extend-shallow": "^3.0.2", + "safe-regex": "^1.1.0" + } + }, + "regexp-tree": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.11.tgz", + "integrity": "sha512-7/l/DgapVVDzZobwMCCgMlqiqyLFJ0cduo/j+3BcDJIB+yJdsYCfKuI3l/04NV+H/rfNRdPIDbXNZHM9XvQatg==", + "dev": true + }, + "regexpu-core": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-4.5.4.tgz", + "integrity": "sha512-BtizvGtFQKGPUcTy56o3nk1bGRp4SZOTYrDtGNlqCQufptV5IkkLN6Emw+yunAJjzf+C9FQFtvq7IoA3+oMYHQ==", + "dev": true, + "requires": { + "regenerate": "^1.4.0", + "regenerate-unicode-properties": "^8.0.2", + "regjsgen": "^0.5.0", + "regjsparser": "^0.6.0", + "unicode-match-property-ecmascript": "^1.0.4", + "unicode-match-property-value-ecmascript": "^1.1.0" + } + }, + "regjsgen": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.5.0.tgz", + "integrity": "sha512-RnIrLhrXCX5ow/E5/Mh2O4e/oa1/jW0eaBKTSy3LaCj+M3Bqvm97GWDp2yUtzIs4LEn65zR2yiYGFqb2ApnzDA==", + "dev": true + }, + "regjsparser": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.6.0.tgz", + "integrity": "sha512-RQ7YyokLiQBomUJuUG8iGVvkgOLxwyZM8k6d3q5SAXpg4r5TZJZigKFvC6PpD+qQ98bCDC5YelPeA3EucDoNeQ==", + "dev": true, + "requires": { + "jsesc": "~0.5.0" + }, + "dependencies": { + "jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=", + "dev": true + } + } + }, + "remove-bom-buffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/remove-bom-buffer/-/remove-bom-buffer-3.0.0.tgz", + "integrity": "sha512-8v2rWhaakv18qcvNeli2mZ/TMTL2nEyAKRvzo1WtnZBl15SHyEhrCu2/xKlJyUFKHiHgfXIyuY6g2dObJJycXQ==", + "dev": true, + "requires": { + "is-buffer": "^1.1.5", + "is-utf8": "^0.2.1" + } + }, + "remove-bom-stream": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/remove-bom-stream/-/remove-bom-stream-1.2.0.tgz", + "integrity": "sha1-BfGlk/FuQuH7kOv1nejlaVJflSM=", + "dev": true, + "requires": { + "remove-bom-buffer": "^3.0.0", + "safe-buffer": "^5.1.0", + "through2": "^2.0.3" + } + }, + "remove-trailing-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=", + "dev": true + }, + "repeat-element": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.3.tgz", + "integrity": "sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g==", + "dev": true + }, + "repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", + "dev": true + }, + "repeating": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", + "integrity": "sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=", + "dev": true, + "requires": { + "is-finite": "^1.0.0" + } + }, + "replace-ext": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-1.0.0.tgz", + "integrity": "sha1-3mMSg3P8v3w8z6TeWkgMRaZ5WOs=", + "dev": true + }, + "replace-homedir": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/replace-homedir/-/replace-homedir-1.0.0.tgz", + "integrity": "sha1-6H9tUTuSjd6AgmDBK+f+xv9ueYw=", + "dev": true, + "requires": { + "homedir-polyfill": "^1.0.1", + "is-absolute": "^1.0.0", + "remove-trailing-separator": "^1.1.0" + } + }, + "request": { + "version": "2.88.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz", + "integrity": "sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==", + "dev": true, + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.0", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.4.3", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + } + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", + "dev": true + }, + "require-main-filename": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz", + "integrity": "sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=", + "dev": true + }, + "resolve": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.11.1.tgz", + "integrity": "sha512-vIpgF6wfuJOZI7KKKSP+HmiKggadPQAdsp5HiC1mvqnfp0gF1vdwgBWZIdrVft9pgqoMFQN+R7BSWZiBxx+BBw==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + }, + "resolve-dir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", + "integrity": "sha1-eaQGRMNivoLybv/nOcm7U4IEb0M=", + "dev": true, + "requires": { + "expand-tilde": "^2.0.0", + "global-modules": "^1.0.0" + } + }, + "resolve-options": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/resolve-options/-/resolve-options-1.1.0.tgz", + "integrity": "sha1-MrueOcBtZzONyTeMDW1gdFZq0TE=", + "dev": true, + "requires": { + "value-or-function": "^3.0.0" + } + }, + "resolve-url": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", + "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=", + "dev": true + }, + "ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true + }, + "reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true + }, + "rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "run-parallel": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.1.9.tgz", + "integrity": "sha512-DEqnSRTDw/Tc3FXf49zedI638Z9onwUotBMiUFKmrO2sdFKIbXamXGQ3Axd4qgphxKB4kw/qP1w5kTxnfU1B9Q==", + "dev": true + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "safe-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", + "dev": true, + "requires": { + "ret": "~0.1.10" + } + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "sass-graph": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/sass-graph/-/sass-graph-2.2.4.tgz", + "integrity": "sha1-E/vWPNHK8JCLn9k0dq1DpR0eC0k=", + "dev": true, + "requires": { + "glob": "^7.0.0", + "lodash": "^4.0.0", + "scss-tokenizer": "^0.2.3", + "yargs": "^7.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "camelcase": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz", + "integrity": "sha1-MvxLn82vhF/N9+c7uXysImHwqwo=", + "dev": true + }, + "cliui": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", + "integrity": "sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=", + "dev": true, + "requires": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wrap-ansi": "^2.0.0" + } + }, + "get-caller-file": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", + "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==", + "dev": true + }, + "invert-kv": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", + "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "lcid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", + "integrity": "sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU=", + "dev": true, + "requires": { + "invert-kv": "^1.0.0" + } + }, + "os-locale": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", + "integrity": "sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=", + "dev": true, + "requires": { + "lcid": "^1.0.0" + } + }, + "require-main-filename": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz", + "integrity": "sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=", + "dev": true + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "which-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-1.0.0.tgz", + "integrity": "sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8=", + "dev": true + }, + "wrap-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", + "dev": true, + "requires": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1" + } + }, + "y18n": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz", + "integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE=", + "dev": true + }, + "yargs": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-7.1.0.tgz", + "integrity": "sha1-a6MY6xaWFyf10oT46gA+jWFU0Mg=", + "dev": true, + "requires": { + "camelcase": "^3.0.0", + "cliui": "^3.2.0", + "decamelize": "^1.1.1", + "get-caller-file": "^1.0.1", + "os-locale": "^1.4.0", + "read-pkg-up": "^1.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^1.0.2", + "which-module": "^1.0.0", + "y18n": "^3.2.1", + "yargs-parser": "^5.0.0" + } + }, + "yargs-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-5.0.0.tgz", + "integrity": "sha1-J17PDX/+Bcd+ZOfIbkzZS/DhIoo=", + "dev": true, + "requires": { + "camelcase": "^3.0.0" + } + } + } + }, + "scss-tokenizer": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz", + "integrity": "sha1-jrBtualyMzOCTT9VMGQRSYR85dE=", + "dev": true, + "requires": { + "js-base64": "^2.1.8", + "source-map": "^0.4.2" + }, + "dependencies": { + "source-map": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", + "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=", + "dev": true, + "requires": { + "amdefine": ">=0.0.4" + } + } + } + }, + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + }, + "semver-greatest-satisfied-range": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/semver-greatest-satisfied-range/-/semver-greatest-satisfied-range-1.1.0.tgz", + "integrity": "sha1-E+jCZYq5aRywzXEJMkAoDTb3els=", + "dev": true, + "requires": { + "sver-compat": "^1.5.0" + } + }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", + "dev": true + }, + "set-value": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "signal-exit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", + "dev": true + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + }, + "snapdragon": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", + "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==", + "dev": true, + "requires": { + "base": "^0.11.1", + "debug": "^2.2.0", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "map-cache": "^0.2.2", + "source-map": "^0.5.6", + "source-map-resolve": "^0.5.0", + "use": "^3.1.0" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "snapdragon-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz", + "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==", + "dev": true, + "requires": { + "define-property": "^1.0.0", + "isobject": "^3.0.0", + "snapdragon-util": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "snapdragon-util": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz", + "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==", + "dev": true, + "requires": { + "kind-of": "^3.2.0" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + }, + "source-map-resolve": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.2.tgz", + "integrity": "sha512-MjqsvNwyz1s0k81Goz/9vRBe9SZdB09Bdw+/zYyO+3CuPk6fouTaxscHkgtE8jKvf01kVfl8riHzERQ/kefaSA==", + "dev": true, + "requires": { + "atob": "^2.1.1", + "decode-uri-component": "^0.2.0", + "resolve-url": "^0.2.1", + "source-map-url": "^0.4.0", + "urix": "^0.1.0" + } + }, + "source-map-support": { + "version": "0.5.12", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.12.tgz", + "integrity": "sha512-4h2Pbvyy15EE02G+JOZpUCmqWJuqrs+sEkzewTm++BPi7Hvn/HwcqLAcNxYAyI0x13CpPPn+kMjl+hplXMHITQ==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "source-map-url": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz", + "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=", + "dev": true + }, + "sparkles": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sparkles/-/sparkles-1.0.1.tgz", + "integrity": "sha512-dSO0DDYUahUt/0/pD/Is3VIm5TGJjludZ0HVymmhYF6eNA53PVLhnUk0znSYbH8IYBuJdCE+1luR22jNLMaQdw==", + "dev": true + }, + "spdx-correct": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.0.tgz", + "integrity": "sha512-lr2EZCctC2BNR7j7WzJ2FpDznxky1sjfxvvYEyzxNyb6lZXHODmEoJeFu4JupYlkfha1KZpJyoqiJ7pgA1qq8Q==", + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.2.0.tgz", + "integrity": "sha512-2XQACfElKi9SlVb1CYadKDXvoajPgBVPn/gOQLrTvHdElaVhr7ZEbqJaRnJLVNeaI4cMEAgVCeBMKF6MWRDCRA==", + "dev": true + }, + "spdx-expression-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz", + "integrity": "sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz", + "integrity": "sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==", + "dev": true + }, + "split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "dev": true, + "requires": { + "extend-shallow": "^3.0.0" + } + }, + "sshpk": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", + "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", + "dev": true, + "requires": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + } + }, + "stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=", + "dev": true + }, + "static-extend": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", + "integrity": "sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=", + "dev": true, + "requires": { + "define-property": "^0.2.5", + "object-copy": "^0.1.0" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "stdout-stream": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/stdout-stream/-/stdout-stream-1.4.1.tgz", + "integrity": "sha512-j4emi03KXqJWcIeF8eIXkjMFN1Cmb8gUlDYGeBALLPo5qdyTfA9bOtl8m33lRoC+vFMkP3gl0WsDr6+gzxbbTA==", + "dev": true, + "requires": { + "readable-stream": "^2.0.1" + } + }, + "stream-exhaust": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stream-exhaust/-/stream-exhaust-1.0.2.tgz", + "integrity": "sha512-b/qaq/GlBK5xaq1yrK9/zFcyRSTNxmcZwFLGSTG0mXgZl/4Z6GgiyYOXOvY7N3eEvFRAG1bkDRz5EPGSvPYQlw==", + "dev": true + }, + "stream-shift": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.0.tgz", + "integrity": "sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI=", + "dev": true + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + }, + "dependencies": { + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + } + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-bom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", + "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=", + "dev": true, + "requires": { + "is-utf8": "^0.2.0" + } + }, + "strip-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-1.0.1.tgz", + "integrity": "sha1-DHlipq3vp7vUrDZkYKY4VSrhoKI=", + "dev": true, + "requires": { + "get-stdin": "^4.0.1" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "sver-compat": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/sver-compat/-/sver-compat-1.5.0.tgz", + "integrity": "sha1-PPh9/rTQe0o/FIJ7wYaz/QxkXNg=", + "dev": true, + "requires": { + "es6-iterator": "^2.0.1", + "es6-symbol": "^3.1.1" + } + }, + "tar": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.2.tgz", + "integrity": "sha512-FCEhQ/4rE1zYv9rYXJw/msRqsnmlje5jHP6huWeBZ704jUTy02c5AZyWujpMR1ax6mVw9NyJMfuK2CMDWVIfgA==", + "dev": true, + "requires": { + "block-stream": "*", + "fstream": "^1.0.12", + "inherits": "2" + } + }, + "through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dev": true, + "requires": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "through2-filter": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/through2-filter/-/through2-filter-3.0.0.tgz", + "integrity": "sha512-jaRjI2WxN3W1V8/FMZ9HKIBXixtiqs3SQSX4/YGIiP3gL6djW48VoZq9tDqeCWs3MT8YY5wb/zli8VW8snY1CA==", + "dev": true, + "requires": { + "through2": "~2.0.0", + "xtend": "~4.0.0" + } + }, + "time-stamp": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/time-stamp/-/time-stamp-1.1.0.tgz", + "integrity": "sha1-dkpaEa9QVhkhsTPztE5hhofg9cM=", + "dev": true + }, + "to-absolute-glob": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz", + "integrity": "sha1-GGX0PZ50sIItufFFt4z/fQ98hJs=", + "dev": true, + "requires": { + "is-absolute": "^1.0.0", + "is-negated-glob": "^1.0.0" + } + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "dev": true + }, + "to-object-path": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", + "integrity": "sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "to-regex": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", + "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==", + "dev": true, + "requires": { + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "regex-not": "^1.0.2", + "safe-regex": "^1.1.0" + } + }, + "to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + } + }, + "to-through": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-through/-/to-through-2.0.0.tgz", + "integrity": "sha1-/JKtq6ByZHvAtn1rA2ZKoZUJOvY=", + "dev": true, + "requires": { + "through2": "^2.0.3" + } + }, + "tough-cookie": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz", + "integrity": "sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==", + "dev": true, + "requires": { + "psl": "^1.1.24", + "punycode": "^1.4.1" + }, + "dependencies": { + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", + "dev": true + } + } + }, + "trim-newlines": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz", + "integrity": "sha1-WIeWa7WCpFA6QetST301ARgVphM=", + "dev": true + }, + "trim-right": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz", + "integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=", + "dev": true + }, + "true-case-path": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/true-case-path/-/true-case-path-1.0.3.tgz", + "integrity": "sha512-m6s2OdQe5wgpFMC+pAJ+q9djG82O2jcHPOI6RNg1yy9rCYR+WD6Nbpl32fDpfC56nirdRy+opFa/Vk7HYhqaew==", + "dev": true, + "requires": { + "glob": "^7.1.2" + } + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "dev": true, + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "dev": true + }, + "type": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/type/-/type-1.0.1.tgz", + "integrity": "sha512-MAM5dBMJCJNKs9E7JXo4CXRAansRfG0nlJxW7Wf6GZzSOvH31zClSaHdIMWLehe/EGMBkqeC55rrkaOr5Oo7Nw==", + "dev": true + }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", + "dev": true + }, + "unc-path-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", + "integrity": "sha1-5z3T17DXxe2G+6xrCufYxqadUPo=", + "dev": true + }, + "undertaker": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/undertaker/-/undertaker-1.2.1.tgz", + "integrity": "sha512-71WxIzDkgYk9ZS+spIB8iZXchFhAdEo2YU8xYqBYJ39DIUIqziK78ftm26eecoIY49X0J2MLhG4hr18Yp6/CMA==", + "dev": true, + "requires": { + "arr-flatten": "^1.0.1", + "arr-map": "^2.0.0", + "bach": "^1.0.0", + "collection-map": "^1.0.0", + "es6-weak-map": "^2.0.1", + "last-run": "^1.1.0", + "object.defaults": "^1.0.0", + "object.reduce": "^1.0.0", + "undertaker-registry": "^1.0.0" + } + }, + "undertaker-registry": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/undertaker-registry/-/undertaker-registry-1.0.1.tgz", + "integrity": "sha1-XkvaMI5KiirlhPm5pDWaSZglzFA=", + "dev": true + }, + "unicode-canonical-property-names-ecmascript": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", + "integrity": "sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ==", + "dev": true + }, + "unicode-match-property-ecmascript": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-1.0.4.tgz", + "integrity": "sha512-L4Qoh15vTfntsn4P1zqnHulG0LdXgjSO035fEpdtp6YxXhMT51Q6vgM5lYdG/5X3MjS+k/Y9Xw4SFCY9IkR0rg==", + "dev": true, + "requires": { + "unicode-canonical-property-names-ecmascript": "^1.0.4", + "unicode-property-aliases-ecmascript": "^1.0.4" + } + }, + "unicode-match-property-value-ecmascript": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.1.0.tgz", + "integrity": "sha512-hDTHvaBk3RmFzvSl0UVrUmC3PuW9wKVnpoUDYH0JDkSIovzw+J5viQmeYHxVSBptubnr7PbH2e0fnpDRQnQl5g==", + "dev": true + }, + "unicode-property-aliases-ecmascript": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.0.5.tgz", + "integrity": "sha512-L5RAqCfXqAwR3RriF8pM0lU0w4Ryf/GgzONwi6KnL1taJQa7x1TCxdJnILX59WIGOwR57IVxn7Nej0fz1Ny6fw==", + "dev": true + }, + "union-value": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", + "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^2.0.1" + } + }, + "unique-stream": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/unique-stream/-/unique-stream-2.3.1.tgz", + "integrity": "sha512-2nY4TnBE70yoxHkDli7DMazpWiP7xMdCYqU2nBRO0UB+ZpEkGsSija7MvmvnZFUeC+mrgiUfcHSr3LmRFIg4+A==", + "dev": true, + "requires": { + "json-stable-stringify-without-jsonify": "^1.0.1", + "through2-filter": "^3.0.0" + } + }, + "unset-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", + "integrity": "sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=", + "dev": true, + "requires": { + "has-value": "^0.3.1", + "isobject": "^3.0.0" + }, + "dependencies": { + "has-value": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz", + "integrity": "sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=", + "dev": true, + "requires": { + "get-value": "^2.0.3", + "has-values": "^0.1.4", + "isobject": "^2.0.0" + }, + "dependencies": { + "isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", + "dev": true, + "requires": { + "isarray": "1.0.0" + } + } + } + }, + "has-values": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz", + "integrity": "sha1-bWHeldkd/Km5oCCJrThL/49it3E=", + "dev": true + } + } + }, + "upath": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.1.2.tgz", + "integrity": "sha512-kXpym8nmDmlCBr7nKdIx8P2jNBa+pBpIUFRnKJ4dr8htyYGJFokkr2ZvERRtUN+9SY+JqXouNgUPtv6JQva/2Q==", + "dev": true + }, + "uri-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", + "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "urix": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", + "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=", + "dev": true + }, + "use": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", + "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", + "dev": true + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true + }, + "uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==", + "dev": true + }, + "v8flags": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-3.1.3.tgz", + "integrity": "sha512-amh9CCg3ZxkzQ48Mhcb8iX7xpAfYJgePHxWMQCBWECpOSqJUXgY26ncA61UTV0BkPqfhcy6mzwCIoP4ygxpW8w==", + "dev": true, + "requires": { + "homedir-polyfill": "^1.0.1" + } + }, + "validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "value-or-function": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/value-or-function/-/value-or-function-3.0.0.tgz", + "integrity": "sha1-HCQ6ULWVwb5Up1S/7OhWO5/42BM=", + "dev": true + }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "vinyl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-2.2.0.tgz", + "integrity": "sha512-MBH+yP0kC/GQ5GwBqrTPTzEfiiLjta7hTtvQtbxBgTeSXsmKQRQecjibMbxIXzVT3Y9KJK+drOz1/k+vsu8Nkg==", + "dev": true, + "requires": { + "clone": "^2.1.1", + "clone-buffer": "^1.0.0", + "clone-stats": "^1.0.0", + "cloneable-readable": "^1.0.0", + "remove-trailing-separator": "^1.0.1", + "replace-ext": "^1.0.0" + } + }, + "vinyl-fs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-3.0.3.tgz", + "integrity": "sha512-vIu34EkyNyJxmP0jscNzWBSygh7VWhqun6RmqVfXePrOwi9lhvRs//dOaGOTRUQr4tx7/zd26Tk5WeSVZitgng==", + "dev": true, + "requires": { + "fs-mkdirp-stream": "^1.0.0", + "glob-stream": "^6.1.0", + "graceful-fs": "^4.0.0", + "is-valid-glob": "^1.0.0", + "lazystream": "^1.0.0", + "lead": "^1.0.0", + "object.assign": "^4.0.4", + "pumpify": "^1.3.5", + "readable-stream": "^2.3.3", + "remove-bom-buffer": "^3.0.0", + "remove-bom-stream": "^1.2.0", + "resolve-options": "^1.1.0", + "through2": "^2.0.0", + "to-through": "^2.0.0", + "value-or-function": "^3.0.0", + "vinyl": "^2.0.0", + "vinyl-sourcemap": "^1.1.0" + } + }, + "vinyl-sourcemap": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/vinyl-sourcemap/-/vinyl-sourcemap-1.1.0.tgz", + "integrity": "sha1-kqgAWTo4cDqM2xHYswCtS+Y7PhY=", + "dev": true, + "requires": { + "append-buffer": "^1.0.2", + "convert-source-map": "^1.5.0", + "graceful-fs": "^4.1.6", + "normalize-path": "^2.1.1", + "now-and-later": "^2.0.0", + "remove-bom-buffer": "^3.0.0", + "vinyl": "^2.0.0" + } + }, + "vinyl-sourcemaps-apply": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/vinyl-sourcemaps-apply/-/vinyl-sourcemaps-apply-0.2.1.tgz", + "integrity": "sha1-q2VJ1h0XLCsbh75cUI0jnI74dwU=", + "dev": true, + "requires": { + "source-map": "^0.5.1" + } + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "which-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-1.0.0.tgz", + "integrity": "sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8=", + "dev": true + }, + "wide-align": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", + "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", + "dev": true, + "requires": { + "string-width": "^1.0.2 || 2" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "wrap-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", + "dev": true, + "requires": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1" + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true + }, + "y18n": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz", + "integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE=", + "dev": true + }, + "yargs": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-7.1.0.tgz", + "integrity": "sha1-a6MY6xaWFyf10oT46gA+jWFU0Mg=", + "dev": true, + "requires": { + "camelcase": "^3.0.0", + "cliui": "^3.2.0", + "decamelize": "^1.1.1", + "get-caller-file": "^1.0.1", + "os-locale": "^1.4.0", + "read-pkg-up": "^1.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^1.0.2", + "which-module": "^1.0.0", + "y18n": "^3.2.1", + "yargs-parser": "^5.0.0" + } + }, + "yargs-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-5.0.0.tgz", + "integrity": "sha1-J17PDX/+Bcd+ZOfIbkzZS/DhIoo=", + "dev": true, + "requires": { + "camelcase": "^3.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5e339a4 --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "newsreader", + "version": "0.1.0", + "description": "Application for viewing RSS feeds", + "main": "index.js", + "scripts": { + "lint": "prettier \"src/newsreader/**/*.js\" --check", + "format": "prettier \"src/newsreader/**/*.js\" --write" + }, + "repository": { + "type": "git", + "url": "[git@git.fudiggity.nl:5000]:sonny/newsreader.git" + }, + "author": "Sonny", + "license": "GPL-3.0-or-later", + "prettier": { + "semi": true, + "trailingComma": "es5", + "singleQuote": false, + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": false, + "arrowParens": "always" + }, + "dependencies": {}, + "devDependencies": { + "@babel/core": "^7.5.4", + "@babel/preset-env": "^7.5.4", + "@babel/register": "^7.4.4", + "del": "^5.0.0", + "gulp": "^4.0.2", + "gulp-babel": "^8.0.0-beta.2", + "gulp-cli": "^2.2.0", + "gulp-concat": "^2.6.1", + "gulp-sass": "^4.0.2", + "node-sass": "^4.12.0", + "prettier": "^1.18.2" + } +} diff --git a/src/newsreader/accounts/templates/accounts/login.html b/src/newsreader/accounts/templates/accounts/login.html new file mode 100644 index 0000000..c9a9bca --- /dev/null +++ b/src/newsreader/accounts/templates/accounts/login.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} + +{% load static %} + +{% block head %} + +{% endblock %} + +{% block content %} +

+ +
+{% endblock %} diff --git a/src/newsreader/accounts/urls.py b/src/newsreader/accounts/urls.py new file mode 100644 index 0000000..61593ed --- /dev/null +++ b/src/newsreader/accounts/urls.py @@ -0,0 +1,9 @@ +from django.urls import include, path + +from newsreader.accounts.views import LoginView, LogoutView + + +urlpatterns = [ + path("login/", LoginView.as_view(), name="login"), + path("logout/", LogoutView.as_view(), name="logout"), +] diff --git a/src/newsreader/accounts/views.py b/src/newsreader/accounts/views.py index 60f00ef..ae33591 100644 --- a/src/newsreader/accounts/views.py +++ b/src/newsreader/accounts/views.py @@ -1 +1,16 @@ -# Create your views here. +from django.contrib.auth.views import LoginView as DjangoLoginView +from django.contrib.auth.views import LogoutView as DjangoLogoutView +from django.urls import reverse_lazy + + +# TODO redirect to homepage when logged in +class LoginView(DjangoLoginView): + template_name = "accounts/login.html" + + def get_success_url(self): + # TODO redirect to homepage + return reverse_lazy("admin:index") + + +class LogoutView(DjangoLogoutView): + next_page = reverse_lazy("accounts:login") diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 812707b..cffe291 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -61,7 +61,7 @@ ROOT_URLCONF = "newsreader.urls" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], + "DIRS": [os.path.join(BASE_DIR, "templates")], "APP_DIRS": True, "OPTIONS": { "context_processors": [ diff --git a/src/newsreader/static/src/scss/accounts/components/form/_form.scss b/src/newsreader/static/src/scss/accounts/components/form/_form.scss new file mode 100644 index 0000000..aa2fb6c --- /dev/null +++ b/src/newsreader/static/src/scss/accounts/components/form/_form.scss @@ -0,0 +1,27 @@ +.login-form { + @extend .form; + + h4 { + margin: 0; + padding: 20px 24px 5px 24px; + } + + &__fieldset { + @extend .form__fieldset; + } + + &__fieldset * { + padding: 10px; + } + + &__fieldset:last-child { + flex-direction: row-reverse; + justify-content: space-between; + } + + &__fieldset:last-child { + .button { + padding: 10px 50px; + } + } +} diff --git a/src/newsreader/static/src/scss/accounts/components/form/index.scss b/src/newsreader/static/src/scss/accounts/components/form/index.scss new file mode 100644 index 0000000..dc477a7 --- /dev/null +++ b/src/newsreader/static/src/scss/accounts/components/form/index.scss @@ -0,0 +1 @@ +@import "form"; diff --git a/src/newsreader/static/src/scss/accounts/components/index.scss b/src/newsreader/static/src/scss/accounts/components/index.scss new file mode 100644 index 0000000..246a1a1 --- /dev/null +++ b/src/newsreader/static/src/scss/accounts/components/index.scss @@ -0,0 +1,2 @@ +@import "./main/index"; +@import "./form/index"; diff --git a/src/newsreader/static/src/scss/accounts/components/main/_main.scss b/src/newsreader/static/src/scss/accounts/components/main/_main.scss new file mode 100644 index 0000000..1efb986 --- /dev/null +++ b/src/newsreader/static/src/scss/accounts/components/main/_main.scss @@ -0,0 +1,8 @@ +.main { + @extend .main; + + margin: 5% auto; + width: 50%; + + border-radius: 4px; +} diff --git a/src/newsreader/static/src/scss/accounts/components/main/index.scss b/src/newsreader/static/src/scss/accounts/components/main/index.scss new file mode 100644 index 0000000..bdb4ce0 --- /dev/null +++ b/src/newsreader/static/src/scss/accounts/components/main/index.scss @@ -0,0 +1 @@ +@import "main"; diff --git a/src/newsreader/static/src/scss/accounts/index.scss b/src/newsreader/static/src/scss/accounts/index.scss new file mode 100644 index 0000000..d0a748c --- /dev/null +++ b/src/newsreader/static/src/scss/accounts/index.scss @@ -0,0 +1,4 @@ +@import "../partials/variables"; +@import "../components/index"; + +@import "./components/index"; diff --git a/src/newsreader/static/src/scss/components/body/_body.scss b/src/newsreader/static/src/scss/components/body/_body.scss new file mode 100644 index 0000000..f0829bf --- /dev/null +++ b/src/newsreader/static/src/scss/components/body/_body.scss @@ -0,0 +1,5 @@ +.body { + margin: 0; + padding: 0; + background-color: $gainsboro; +} diff --git a/src/newsreader/static/src/scss/components/body/index.scss b/src/newsreader/static/src/scss/components/body/index.scss new file mode 100644 index 0000000..533e39e --- /dev/null +++ b/src/newsreader/static/src/scss/components/body/index.scss @@ -0,0 +1 @@ +@import "body"; diff --git a/src/newsreader/static/src/scss/components/button/_button.scss b/src/newsreader/static/src/scss/components/button/_button.scss new file mode 100644 index 0000000..4047b6c --- /dev/null +++ b/src/newsreader/static/src/scss/components/button/_button.scss @@ -0,0 +1,28 @@ +.button { + display: flex; + + align-items: center; + justify-content: center; + + padding: 10px 50px; + + width: 50px; + + border: none; + border-radius: 2px; + + font-family: $button-font; + + &:hover { + cursor: pointer; + } + + &--confirm { + color: $white; + background-color: $confirm-green; + + &:hover { + background-color: lighten($confirm-green, +5%); + } + } +} diff --git a/src/newsreader/static/src/scss/components/button/index.scss b/src/newsreader/static/src/scss/components/button/index.scss new file mode 100644 index 0000000..ac3b5de --- /dev/null +++ b/src/newsreader/static/src/scss/components/button/index.scss @@ -0,0 +1 @@ +@import "button"; diff --git a/src/newsreader/static/src/scss/components/error/_error.scss b/src/newsreader/static/src/scss/components/error/_error.scss new file mode 100644 index 0000000..bc726d2 --- /dev/null +++ b/src/newsreader/static/src/scss/components/error/_error.scss @@ -0,0 +1,5 @@ +%error { + background-color: $error-red; + color: $white; + list-style: none; +} diff --git a/src/newsreader/static/src/scss/components/error/_errorlist.scss b/src/newsreader/static/src/scss/components/error/_errorlist.scss new file mode 100644 index 0000000..6f13d9a --- /dev/null +++ b/src/newsreader/static/src/scss/components/error/_errorlist.scss @@ -0,0 +1,3 @@ +.errorlist { + @extend %error; +} diff --git a/src/newsreader/static/src/scss/components/error/index.scss b/src/newsreader/static/src/scss/components/error/index.scss new file mode 100644 index 0000000..baf8ad8 --- /dev/null +++ b/src/newsreader/static/src/scss/components/error/index.scss @@ -0,0 +1,2 @@ +@import "error"; +@import "errorlist"; diff --git a/src/newsreader/static/src/scss/components/form/_form.scss b/src/newsreader/static/src/scss/components/form/_form.scss new file mode 100644 index 0000000..8b0b3f6 --- /dev/null +++ b/src/newsreader/static/src/scss/components/form/_form.scss @@ -0,0 +1,13 @@ +.form { + display: flex; + flex-direction: column; + + font-family: $form-font; + + &__fieldset { + display: flex; + flex-direction: column; + + border: none; + } +} diff --git a/src/newsreader/static/src/scss/components/form/index.scss b/src/newsreader/static/src/scss/components/form/index.scss new file mode 100644 index 0000000..dc477a7 --- /dev/null +++ b/src/newsreader/static/src/scss/components/form/index.scss @@ -0,0 +1 @@ +@import "form"; diff --git a/src/newsreader/static/src/scss/components/index.scss b/src/newsreader/static/src/scss/components/index.scss new file mode 100644 index 0000000..14ddc8e --- /dev/null +++ b/src/newsreader/static/src/scss/components/index.scss @@ -0,0 +1,6 @@ +@import "./body/index"; +@import "./button/index"; +@import "./form/index"; +@import "./main/index"; +@import "./navbar/index"; +@import "./error/index"; diff --git a/src/newsreader/static/src/scss/components/input/input.scss b/src/newsreader/static/src/scss/components/input/input.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/static/src/scss/components/main/_main.scss b/src/newsreader/static/src/scss/components/main/_main.scss new file mode 100644 index 0000000..1c4ed2e --- /dev/null +++ b/src/newsreader/static/src/scss/components/main/_main.scss @@ -0,0 +1,4 @@ +.main { + margin: 1% 10% 5% 10%; + background-color: $white; +} diff --git a/src/newsreader/static/src/scss/components/main/index.scss b/src/newsreader/static/src/scss/components/main/index.scss new file mode 100644 index 0000000..bdb4ce0 --- /dev/null +++ b/src/newsreader/static/src/scss/components/main/index.scss @@ -0,0 +1 @@ +@import "main"; diff --git a/src/newsreader/static/src/scss/components/navbar/_navbar.scss b/src/newsreader/static/src/scss/components/navbar/_navbar.scss new file mode 100644 index 0000000..d0ea3b9 --- /dev/null +++ b/src/newsreader/static/src/scss/components/navbar/_navbar.scss @@ -0,0 +1,43 @@ +.nav { + display: flex; + justify-content: center; + + width: 100%; + + background-color: $white; + box-shadow: 0px 5px darken($azureish-white, +10%); + + ol { + display: flex; + justify-content: flex-start; + + width: 80%; + list-style-type: none; + } + + a { + color: $nickel; + text-decoration: none; + } + + &__item { + margin: 0px 10px; + + border: none; + border-radius: 2px; + + background-color: $azureish-white; + + &:hover{ + background-color: lighten($azureish-white, +5%); + } + + & a { + @extend .button; + } + } + + &__item:last-child { + margin: 0 10px 0 auto; + } +} diff --git a/src/newsreader/static/src/scss/components/navbar/index.scss b/src/newsreader/static/src/scss/components/navbar/index.scss new file mode 100644 index 0000000..b45a5a0 --- /dev/null +++ b/src/newsreader/static/src/scss/components/navbar/index.scss @@ -0,0 +1 @@ +@import "navbar"; diff --git a/src/newsreader/static/src/scss/partials/_variables.scss b/src/newsreader/static/src/scss/partials/_variables.scss new file mode 100644 index 0000000..d983d2a --- /dev/null +++ b/src/newsreader/static/src/scss/partials/_variables.scss @@ -0,0 +1,26 @@ +@import url("https://fonts.googleapis.com/css?family=IBM+Plex+Sans:600&display=swap"); +@import url('https://fonts.googleapis.com/css?family=Barlow&display=swap'); + +$button-font: "IBM Plex Sans", sans-serif; +$form-font: "Barlow", sans-serif; + +/* colors */ +$white: rgba(255, 255, 255, 1); + +$confirm-green: rgba(46,204,113, 1); +$error-red: rgba(231,76,60, 1); + +// light blue +$azureish-white: rgba(205, 230, 245, 1); + +// dark blue +$pewter-blue: rgba(141, 167, 190, 1); + +// light gray +$gainsboro: rgba(224, 221, 220, 1); + +// medium gray +$roman-silver: rgba(135, 145, 158, 1); + +//dark gray +$nickel: rgba(112, 112, 120, 1); diff --git a/src/newsreader/templates/base.html b/src/newsreader/templates/base.html new file mode 100644 index 0000000..460ab8f --- /dev/null +++ b/src/newsreader/templates/base.html @@ -0,0 +1,33 @@ + + + + Newreader + {% block head %}{% endblock %} + + + +
+ + {% if messages %} +
    + {% for message in messages %} + + {{ message }} + + {% endfor %} +
+ {% endif %} + + {% block content %}{% endblock content %} + + diff --git a/src/newsreader/urls.py b/src/newsreader/urls.py index e8a3b87..5b30662 100644 --- a/src/newsreader/urls.py +++ b/src/newsreader/urls.py @@ -2,6 +2,7 @@ from django.conf import settings from django.contrib import admin from django.urls import include, path +from newsreader.accounts.urls import urlpatterns as login_urls from newsreader.news.collection.urls import endpoints as collection_endpoints from newsreader.news.core.urls import endpoints as core_endpoints @@ -9,6 +10,7 @@ from newsreader.news.core.urls import endpoints as core_endpoints endpoints = collection_endpoints + core_endpoints urlpatterns = [ + path("accounts/", include((login_urls, "accounts")), name="accounts"), path("admin/", admin.site.urls, name="admin"), path("api/", include((endpoints, "api")), name="api"), ] From 0658d6404f67a8f0e746077c4412fef1118a230b Mon Sep 17 00:00:00 2001 From: Sonny Date: Sat, 10 Aug 2019 21:12:59 +0200 Subject: [PATCH 018/422] Add docker specific files --- Dockerfile | 14 ++++++++++++++ docker-compose.yml | 31 +++++++++++++++++++++++++++++++ src/entrypoint.sh | 5 +++++ src/newsreader/celery.py | 4 +++- src/newsreader/conf/docker.py | 17 +++++++++++++++++ 5 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100755 src/entrypoint.sh create mode 100644 src/newsreader/conf/docker.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bbdfe24 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.7-buster + +RUN mkdir /app +WORKDIR /app + +# Use a seperate layer for the project requirements +COPY ./requirements /app/requirements +RUN pip install -r requirements/dev.txt + +COPY . /app/ + +# Set the default shell & add a home dir +RUN useradd -ms /bin/bash newsreader +USER newsreader diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..182821d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +version: '3' + +services: + db: + # See https://hub.docker.com/_/postgres + image: postgres + environment: + - POSTGRES_USER=newsreader + - POSTGRES_DB=newsreader + web: + build: . + command: src/entrypoint.sh + environment: + - DJANGO_SETTINGS_MODULE=newsreader.conf.docker + volumes: + - .:/app + ports: + - '8000:8000' + depends_on: + - db + rabbitmq: + image: rabbitmq:3.7 + celery: + build: . + command: celery -A newsreader worker --beat --scheduler django --loglevel=info --workdir=/app/src/ + environment: + - DJANGO_SETTINGS_MODULE=newsreader.conf.docker + volumes: + - .:/app + depends_on: + - rabbitmq diff --git a/src/entrypoint.sh b/src/entrypoint.sh new file mode 100755 index 0000000..3fbf941 --- /dev/null +++ b/src/entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# This file should only be used in conjuction with docker-compose + +python /app/src/manage.py migrate +python /app/src/manage.py runserver 0.0.0.0:8000 diff --git a/src/newsreader/celery.py b/src/newsreader/celery.py index 4aeb7a1..aa15a08 100644 --- a/src/newsreader/celery.py +++ b/src/newsreader/celery.py @@ -7,6 +7,8 @@ from celery import Celery os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.dev") # note: use the --workdir flag when running from different directories -app = Celery("newsreader", broker="amqp://") +app = Celery("newsreader") + +app.config_from_object("django.conf:settings") app.autodiscover_tasks() diff --git a/src/newsreader/conf/docker.py b/src/newsreader/conf/docker.py new file mode 100644 index 0000000..d8e3142 --- /dev/null +++ b/src/newsreader/conf/docker.py @@ -0,0 +1,17 @@ +from .dev import * + + +# Database +# https://docs.djangoproject.com/en/2.2/ref/settings/#databases +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql_psycopg2", + "NAME": "newsreader", + "USER": "newsreader", + "HOST": "db", + } +} + +# Celery +# https://docs.celeryproject.org/en/latest/userguide/configuration.html +BROKER_URL = "amqp://guest:guest@rabbitmq:5672//" From 8c3bc408b91c726d320f2bd1cb78e90627e71c8c Mon Sep 17 00:00:00 2001 From: Sonny Date: Sat, 10 Aug 2019 22:28:00 +0200 Subject: [PATCH 019/422] Add a default fixture --- src/newsreader/fixtures/default-fixture.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/newsreader/fixtures/default-fixture.json diff --git a/src/newsreader/fixtures/default-fixture.json b/src/newsreader/fixtures/default-fixture.json new file mode 100644 index 0000000..514930a --- /dev/null +++ b/src/newsreader/fixtures/default-fixture.json @@ -0,0 +1 @@ +[{"model": "accounts.user", "pk": 1, "fields": {"password": "pbkdf2_sha256$150000$OeNoz2LRSpI5$jkkUf/BjTuWZULyldvNTt9f45/ErxaCmCQHfZwtzji8=", "last_login": "2019-08-10T18:07:27.224Z", "is_superuser": true, "first_name": "", "last_name": "", "is_staff": true, "is_active": true, "date_joined": "2019-08-10T18:07:19.699Z", "email": "sonny@newsreader.nl", "task": null, "task_interval": null, "groups": [], "user_permissions": []}}, {"model": "django_celery_beat.intervalschedule", "pk": 1, "fields": {"every": 5, "period": "minutes"}}, {"model": "django_celery_beat.intervalschedule", "pk": 2, "fields": {"every": 30, "period": "minutes"}}, {"model": "django_celery_beat.intervalschedule", "pk": 3, "fields": {"every": 3, "period": "hours"}}, {"model": "django_celery_beat.crontabschedule", "pk": 1, "fields": {"minute": "0", "hour": "4", "day_of_week": "*", "day_of_month": "*", "month_of_year": "*", "timezone": "UTC"}}, {"model": "django_celery_beat.periodictasks", "pk": 1, "fields": {"last_update": "2019-08-10T20:01:21.152Z"}}, {"model": "django_celery_beat.periodictask", "pk": 1, "fields": {"name": "celery.backend_cleanup", "task": "celery.backend_cleanup", "interval": null, "crontab": 1, "solar": null, "clocked": null, "args": "[]", "kwargs": "{}", "queue": null, "exchange": null, "routing_key": null, "headers": "{}", "priority": null, "expires": null, "one_off": false, "start_time": null, "enabled": true, "last_run_at": null, "total_run_count": 0, "date_changed": "2019-08-10T19:56:33.160Z", "description": ""}}, {"model": "django_celery_beat.periodictask", "pk": 3, "fields": {"name": "Collection testing task", "task": "newsreader.news.collection.tasks.collect", "interval": 3, "crontab": null, "solar": null, "clocked": null, "args": "[1]", "kwargs": "{}", "queue": null, "exchange": null, "routing_key": null, "headers": "{}", "priority": null, "expires": null, "one_off": false, "start_time": "2019-08-10T20:00:52Z", "enabled": true, "last_run_at": null, "total_run_count": 0, "date_changed": "2019-08-10T20:01:21.153Z", "description": ""}}, {"model": "core.category", "pk": 1, "fields": {"created": "2019-08-10T19:57:53Z", "modified": "2019-08-10T19:58:02.048Z", "name": "Tech", "user": 1}}, {"model": "core.category", "pk": 2, "fields": {"created": "2019-08-10T19:58:13Z", "modified": "2019-08-10T19:58:20.951Z", "name": "News", "user": 1}}, {"model": "collection.collectionrule", "pk": 1, "fields": {"created": "2019-08-10T18:08:10.520Z", "modified": "2019-08-10T19:59:54.547Z", "name": "Tweakers", "url": "http://feeds.feedburner.com/tweakers/mixed", "website_url": null, "favicon": null, "timezone": "Europe/Amsterdam", "category": 1, "last_suceeded": "2019-08-10T18:25:32.325Z", "succeeded": true, "error": null, "user": 1}}, {"model": "collection.collectionrule", "pk": 2, "fields": {"created": "2019-08-10T19:58:05.615Z", "modified": "2019-08-10T19:58:05.691Z", "name": "Hackers News", "url": "https://news.ycombinator.com/rss", "website_url": null, "favicon": null, "timezone": "UTC", "category": 1, "last_suceeded": null, "succeeded": false, "error": null, "user": 1}}, {"model": "collection.collectionrule", "pk": 3, "fields": {"created": "2019-08-10T19:58:23.441Z", "modified": "2019-08-10T19:58:23.449Z", "name": "BBC", "url": "http://feeds.bbci.co.uk/news/world/rss.xml", "website_url": null, "favicon": null, "timezone": "UTC", "category": 2, "last_suceeded": null, "succeeded": false, "error": null, "user": 1}}, {"model": "collection.collectionrule", "pk": 4, "fields": {"created": "2019-08-10T19:58:31.867Z", "modified": "2019-08-10T19:58:31.873Z", "name": "Ars Technica", "url": "http://feeds.arstechnica.com/arstechnica/index?fmt=xml", "website_url": null, "favicon": null, "timezone": "UTC", "category": 1, "last_suceeded": null, "succeeded": false, "error": null, "user": 1}}, {"model": "collection.collectionrule", "pk": 5, "fields": {"created": "2019-08-10T19:58:48.529Z", "modified": "2019-08-10T19:58:48.535Z", "name": "The Guardian", "url": "https://www.theguardian.com/world/rss", "website_url": null, "favicon": null, "timezone": "UTC", "category": 2, "last_suceeded": null, "succeeded": false, "error": null, "user": 1}}, {"model": "collection.collectionrule", "pk": 6, "fields": {"created": "2019-08-10T19:58:58.641Z", "modified": "2019-08-10T19:58:58.647Z", "name": "Engadget", "url": "https://www.engadget.com/rss.xml", "website_url": null, "favicon": null, "timezone": "UTC", "category": 1, "last_suceeded": null, "succeeded": false, "error": null, "user": 1}}, {"model": "collection.collectionrule", "pk": 7, "fields": {"created": "2019-08-10T19:59:29.909Z", "modified": "2019-08-10T19:59:29.917Z", "name": "The Verge", "url": "https://www.theverge.com/rss/index.xml", "website_url": null, "favicon": null, "timezone": "UTC", "category": 1, "last_suceeded": null, "succeeded": false, "error": null, "user": 1}}, {"model": "collection.collectionrule", "pk": 8, "fields": {"created": "2019-08-10T19:59:44.833Z", "modified": "2019-08-10T19:59:44.838Z", "name": "News", "url": "http://feeds.boingboing.net/boingboing/iBag", "website_url": null, "favicon": null, "timezone": "UTC", "category": 2, "last_suceeded": null, "succeeded": false, "error": null, "user": 1}}] \ No newline at end of file From 752ba62aee7e181491e333611460fadf0858e3c0 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 18 Aug 2019 11:49:04 +0200 Subject: [PATCH 020/422] Add pre-commit config --- .pre-commit-config.yaml | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e5b4349 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,39 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +default_language_version: + python: python3.7 + +repos: + - repo: local + hooks: + - id: autoflake + name: autoflake + entry: autoflake + language: system + types: [python] + args: ["--in-place", "--remove-unused-variables"] + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: pretty-format-json + + - repo: https://github.com/psf/black + rev: 19.3b0 + hooks: + - id: black + args: [--line-length=90] + + - repo: https://github.com/timothycrosley/isort + rev: 4.3.21-2 + hooks: + - id: isort + + - repo: https://github.com/prettier/prettier + rev: 1.18.2 + hooks: + - id: prettier From 61702e720a846e35076153530ee603417a8a1fa4 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 25 Aug 2019 10:06:51 +0200 Subject: [PATCH 021/422] Add swagger integration --- requirements/base.txt | 1 + src/newsreader/conf/base.py | 1 + src/newsreader/fixtures/local/fixture.json | 168 +++++++++++++++++++++ src/newsreader/urls.py | 9 +- 4 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 src/newsreader/fixtures/local/fixture.json diff --git a/requirements/base.txt b/requirements/base.txt index c9686ee..25bf4ff 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -6,6 +6,7 @@ chardet==3.0.4 Django==2.2 django-celery-beat==1.5.0 djangorestframework==3.9.4 +django-rest-swagger-2.2.0 lxml==4.3.4 feedparser==5.2.1 idna==2.8 diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index cffe291..6d5e1b0 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -38,6 +38,7 @@ INSTALLED_APPS = [ "django.contrib.staticfiles", # third party apps "rest_framework", + "rest_framework_swagger", "celery", "django_celery_beat", # app modules diff --git a/src/newsreader/fixtures/local/fixture.json b/src/newsreader/fixtures/local/fixture.json new file mode 100644 index 0000000..ffcc4fd --- /dev/null +++ b/src/newsreader/fixtures/local/fixture.json @@ -0,0 +1,168 @@ +[ + { + "fields" : { + "is_active" : true, + "is_superuser" : true, + "task_interval" : null, + "user_permissions" : [], + "is_staff" : true, + "last_name" : "", + "first_name" : "", + "groups" : [], + "date_joined" : "2019-07-14T10:44:35.228Z", + "password" : "pbkdf2_sha256$150000$vAOYP6XgN40C$bvW265Is2toKzEnbMmLVufd+DA6z1kIhUv/bhtUiDcA=", + "task" : null, + "last_login" : "2019-07-14T12:28:05.473Z", + "email" : "sonnyba871@gmail.com" + }, + "pk" : 1, + "model" : "accounts.user" + }, + { + "model" : "accounts.user", + "fields" : { + "task" : null, + "email" : "sonny@bakker.nl", + "last_login" : "2019-07-20T07:52:59.491Z", + "first_name" : "", + "groups" : [], + "last_name" : "", + "password" : "pbkdf2_sha256$150000$SMI9E7GFkJQk$usX0YN3q0ArqAd6bUQ9sUm6Ugms3XRxaiizHGIa3Pk4=", + "date_joined" : "2019-07-18T18:52:36.080Z", + "is_staff" : true, + "task_interval" : null, + "user_permissions" : [], + "is_active" : true, + "is_superuser" : true + }, + "pk" : 2 + }, + { + "pk" : 3, + "fields" : { + "favicon" : null, + "category" : null, + "url" : "https://news.ycombinator.com/rss", + "error" : null, + "user" : 2, + "succeeded" : true, + "modified" : "2019-07-20T11:28:16.473Z", + "last_suceeded" : "2019-07-20T11:28:16.316Z", + "name" : "Hackers News", + "website_url" : null, + "created" : "2019-07-14T13:08:10.374Z", + "timezone" : "UTC" + }, + "model" : "collection.collectionrule" + }, + { + "model" : "collection.collectionrule", + "pk" : 4, + "fields" : { + "favicon" : null, + "category" : 2, + "url" : "http://feeds.bbci.co.uk/news/world/rss.xml", + "error" : null, + "user" : 2, + "succeeded" : true, + "last_suceeded" : "2019-07-20T11:28:15.691Z", + "name" : "BBC", + "modified" : "2019-07-20T12:07:49.164Z", + "timezone" : "UTC", + "website_url" : null, + "created" : "2019-07-20T11:24:32.745Z" + } + }, + { + "pk" : 5, + "fields" : { + "error" : null, + "category" : null, + "url" : "http://feeds.arstechnica.com/arstechnica/index?fmt=xml", + "favicon" : null, + "timezone" : "UTC", + "created" : "2019-07-20T11:24:50.411Z", + "website_url" : null, + "name" : "Ars Technica", + "succeeded" : true, + "last_suceeded" : "2019-07-20T11:28:15.986Z", + "modified" : "2019-07-20T11:28:16.033Z", + "user" : 2 + }, + "model" : "collection.collectionrule" + }, + { + "model" : "collection.collectionrule", + "pk" : 6, + "fields" : { + "favicon" : null, + "category" : 2, + "url" : "https://www.theguardian.com/world/rss", + "error" : null, + "user" : 2, + "name" : "The Guardian", + "succeeded" : true, + "last_suceeded" : "2019-07-20T11:28:16.078Z", + "modified" : "2019-07-20T12:07:44.292Z", + "created" : "2019-07-20T11:25:02.089Z", + "website_url" : null, + "timezone" : "UTC" + } + }, + { + "fields" : { + "url" : "http://feeds.feedburner.com/tweakers/mixed?fmt=xml", + "category" : 1, + "error" : null, + "favicon" : null, + "timezone" : "UTC", + "website_url" : null, + "created" : "2019-07-20T11:25:30.121Z", + "user" : 2, + "last_suceeded" : "2019-07-20T11:28:15.860Z", + "succeeded" : true, + "modified" : "2019-07-20T12:07:28.473Z", + "name" : "Tweakers" + }, + "pk" : 7, + "model" : "collection.collectionrule" + }, + { + "model" : "collection.collectionrule", + "pk" : 8, + "fields" : { + "category" : 1, + "url" : "https://www.theverge.com/rss/index.xml", + "error" : null, + "favicon" : null, + "created" : "2019-07-20T11:25:46.256Z", + "website_url" : null, + "timezone" : "UTC", + "user" : 2, + "last_suceeded" : "2019-07-20T11:28:16.034Z", + "succeeded" : true, + "modified" : "2019-07-20T12:07:21.704Z", + "name" : "The Verge" + } + }, + { + "pk" : 1, + "fields" : { + "user" : 2, + "name" : "Tech", + "modified" : "2019-07-20T12:07:17.396Z", + "created" : "2019-07-20T12:07:10Z" + }, + "model" : "core.category" + }, + { + "model" : "core.category", + "pk" : 2, + "fields" : { + "user" : 2, + "modified" : "2019-07-20T12:07:42.329Z", + "name" : "World News", + "created" : "2019-07-20T12:07:34Z" + } + } +] diff --git a/src/newsreader/urls.py b/src/newsreader/urls.py index 5b30662..6271598 100644 --- a/src/newsreader/urls.py +++ b/src/newsreader/urls.py @@ -2,12 +2,19 @@ from django.conf import settings from django.contrib import admin from django.urls import include, path +from rest_framework_swagger.views import get_swagger_view + from newsreader.accounts.urls import urlpatterns as login_urls from newsreader.news.collection.urls import endpoints as collection_endpoints from newsreader.news.core.urls import endpoints as core_endpoints -endpoints = collection_endpoints + core_endpoints +schema_view = get_swagger_view(title="Newsreader API") +endpoints = [ + path("", schema_view, name="schema-view"), + *collection_endpoints, + *core_endpoints, +] urlpatterns = [ path("accounts/", include((login_urls, "accounts")), name="accounts"), From 858f84aaadf7854b4f075944d2f5d1fdb38ea267 Mon Sep 17 00:00:00 2001 From: sonny Date: Mon, 28 Oct 2019 21:35:19 +0100 Subject: [PATCH 022/422] Refactor endpoint tests Replace force_login calls with login call from client class in setUp --- .babelrc | 10 +- .gitignore | 1 + .gitlab-ci.yml | 56 +- .prettierrc.json | 10 + Dockerfile | 16 +- docker-compose.yml | 26 +- gulp/babel.js | 28 + gulp/sass.js | 30 +- gulpfile.babel.js | 25 +- package-lock.json | 1367 ++++++++++++++++- package.json | 36 +- requirements/base.txt | 2 +- requirements/gitlab.txt | 5 +- src/newsreader/accounts/views.py | 4 +- src/newsreader/conf/base.py | 8 + src/newsreader/fixtures/default-fixture.json | 283 +++- .../js/components/LoadingIndicator.js | 13 + src/newsreader/js/homepage/App.js | 63 + .../js/homepage/actions/categories.js | 86 ++ src/newsreader/js/homepage/actions/posts.js | 119 ++ src/newsreader/js/homepage/actions/rules.js | 68 + .../js/homepage/actions/selected.js | 65 + .../js/homepage/components/PostModal.js | 84 + .../homepage/components/feedlist/FeedList.js | 102 ++ .../homepage/components/feedlist/PostItem.js | 50 + .../homepage/components/feedlist/RuleItem.js | 25 + .../homepage/components/feedlist/filters.js | 44 + .../components/sidebar/CategoryItem.js | 68 + .../homepage/components/sidebar/ReadButton.js | 36 + .../homepage/components/sidebar/RuleItem.js | 56 + .../js/homepage/components/sidebar/Sidebar.js | 52 + .../js/homepage/components/sidebar/filters.js | 7 + src/newsreader/js/homepage/configureStore.js | 18 + src/newsreader/js/homepage/index.js | 16 + .../js/homepage/reducers/categories.js | 116 ++ src/newsreader/js/homepage/reducers/index.js | 10 + src/newsreader/js/homepage/reducers/posts.js | 67 + src/newsreader/js/homepage/reducers/rules.js | 65 + .../js/homepage/reducers/selected.js | 70 + src/newsreader/js/utils.js | 14 + src/newsreader/news/collection/admin.py | 3 +- src/newsreader/news/collection/endpoints.py | 69 + src/newsreader/news/collection/serializers.py | 18 +- .../endpoints/{rules => rule}/__init__.py | 0 .../{rules => rule}/detail/__init__.py | 0 .../endpoints/{rules => rule}/detail/tests.py | 157 +- .../{rules => rule}/list/__init__.py | 0 .../tests/endpoints/rule/list/tests.py | 371 +++++ .../tests/endpoints/rules/list/tests.py | 210 --- src/newsreader/news/collection/urls.py | 14 +- src/newsreader/news/collection/views.py | 21 - src/newsreader/news/core/endpoints.py | 118 ++ src/newsreader/news/core/filters.py | 32 + .../news/core/migrations/0003_post_read.py | 14 + src/newsreader/news/core/models.py | 4 +- src/newsreader/news/core/serializers.py | 23 +- .../news/core/templates/core/main.html | 15 + .../tests/endpoints/category/detail/tests.py | 183 ++- .../tests/endpoints/category/list/tests.py | 538 ++++++- .../core/tests/endpoints/post/detail/tests.py | 57 +- .../core/tests/endpoints/post/list/tests.py | 60 +- src/newsreader/news/core/tests/factories.py | 2 + src/newsreader/news/core/urls.py | 34 +- src/newsreader/news/core/views.py | 58 +- .../scss/accounts/components/form/_form.scss | 2 + .../scss/accounts/components/form/index.scss | 0 .../scss/accounts/components/index.scss | 0 .../scss/accounts/components/main/_main.scss | 0 .../scss/accounts/components/main/index.scss | 0 .../{static/src => }/scss/accounts/index.scss | 2 + .../src => }/scss/components/body/_body.scss | 5 + .../src => }/scss/components/body/index.scss | 0 .../scss/components/button/_button.scss | 2 - .../scss/components/button/index.scss | 0 .../scss/components/error/_error.scss | 0 .../scss/components/error/_errorlist.scss | 0 .../src => }/scss/components/error/index.scss | 0 .../src => }/scss/components/form/_form.scss | 0 .../src => }/scss/components/form/index.scss | 0 .../src => }/scss/components/index.scss | 2 + .../src => }/scss/components/input/input.scss | 0 .../loading-indicator/_loading-indicator.scss | 41 + .../components/loading-indicator/index.scss | 1 + .../src => }/scss/components/main/_main.scss | 0 .../src => }/scss/components/main/index.scss | 0 .../scss/components/modal/_modal.scss | 9 + .../scss/components/modal/index.scss | 1 + .../scss/components/navbar/_navbar.scss | 1 + .../scss/components/navbar/index.scss | 0 .../components/categories/_categories.scss | 24 + .../homepage/components/categories/index.scss | 1 + .../components/category/_category.scss | 46 + .../homepage/components/category/index.scss | 1 + .../homepage/components/content/_content.scss | 7 + .../homepage/components/content/index.scss | 1 + .../scss/homepage/components/index.scss | 17 + .../scss/homepage/components/main/_main.scss | 12 + .../scss/homepage/components/main/index.scss | 1 + .../components/post-block/_post-block.scss | 12 + .../homepage/components/post-block/index.scss | 1 + .../post-message/_post-message.scss | 25 + .../components/post-message/index.scss | 1 + .../scss/homepage/components/post/_post.scss | 125 ++ .../scss/homepage/components/post/index.scss | 1 + .../posts-header/_posts-header.scss | 21 + .../components/posts-header/index.scss | 1 + .../components/posts-section/index.scss | 12 + .../homepage/components/posts/_posts.scss | 34 + .../scss/homepage/components/posts/index.scss | 1 + .../components/read-button/_read-button.scss | 10 + .../components/read-button/index.scss | 1 + .../scss/homepage/components/rule/_rule.scss | 10 + .../scss/homepage/components/rule/index.scss | 1 + .../homepage/components/rules/_rules.scss | 29 + .../scss/homepage/components/rules/index.scss | 1 + .../homepage/components/sidebar/_sidebar.scss | 11 + .../homepage/components/sidebar/index.scss | 1 + .../scss/homepage/elements/badge/_badge.scss | 15 + .../scss/homepage/elements/badge/index.scss | 1 + .../scss/homepage/elements/index.scss | 1 + src/newsreader/scss/homepage/index.scss | 7 + src/newsreader/scss/partials/_variables.scss | 42 + src/newsreader/static/icons/angle-down.svg | 1 + src/newsreader/static/icons/angle-right.svg | 1 + src/newsreader/static/icons/arrow-left.svg | 6 + src/newsreader/static/icons/chevron-down.svg | 6 + src/newsreader/static/icons/chevron-right.svg | 6 + src/newsreader/static/icons/link.svg | 1 + src/newsreader/static/icons/times.svg | 1 + .../static/src/scss/partials/_variables.scss | 26 - src/newsreader/templates/base.html | 6 +- src/newsreader/urls.py | 3 + 132 files changed, 5158 insertions(+), 661 deletions(-) create mode 100644 .prettierrc.json create mode 100644 gulp/babel.js create mode 100644 src/newsreader/js/components/LoadingIndicator.js create mode 100644 src/newsreader/js/homepage/App.js create mode 100644 src/newsreader/js/homepage/actions/categories.js create mode 100644 src/newsreader/js/homepage/actions/posts.js create mode 100644 src/newsreader/js/homepage/actions/rules.js create mode 100644 src/newsreader/js/homepage/actions/selected.js create mode 100644 src/newsreader/js/homepage/components/PostModal.js create mode 100644 src/newsreader/js/homepage/components/feedlist/FeedList.js create mode 100644 src/newsreader/js/homepage/components/feedlist/PostItem.js create mode 100644 src/newsreader/js/homepage/components/feedlist/RuleItem.js create mode 100644 src/newsreader/js/homepage/components/feedlist/filters.js create mode 100644 src/newsreader/js/homepage/components/sidebar/CategoryItem.js create mode 100644 src/newsreader/js/homepage/components/sidebar/ReadButton.js create mode 100644 src/newsreader/js/homepage/components/sidebar/RuleItem.js create mode 100644 src/newsreader/js/homepage/components/sidebar/Sidebar.js create mode 100644 src/newsreader/js/homepage/components/sidebar/filters.js create mode 100644 src/newsreader/js/homepage/configureStore.js create mode 100644 src/newsreader/js/homepage/index.js create mode 100644 src/newsreader/js/homepage/reducers/categories.js create mode 100644 src/newsreader/js/homepage/reducers/index.js create mode 100644 src/newsreader/js/homepage/reducers/posts.js create mode 100644 src/newsreader/js/homepage/reducers/rules.js create mode 100644 src/newsreader/js/homepage/reducers/selected.js create mode 100644 src/newsreader/js/utils.js create mode 100644 src/newsreader/news/collection/endpoints.py rename src/newsreader/news/collection/tests/endpoints/{rules => rule}/__init__.py (100%) rename src/newsreader/news/collection/tests/endpoints/{rules => rule}/detail/__init__.py (100%) rename src/newsreader/news/collection/tests/endpoints/{rules => rule}/detail/tests.py (59%) rename src/newsreader/news/collection/tests/endpoints/{rules => rule}/list/__init__.py (100%) create mode 100644 src/newsreader/news/collection/tests/endpoints/rule/list/tests.py delete mode 100644 src/newsreader/news/collection/tests/endpoints/rules/list/tests.py create mode 100644 src/newsreader/news/core/endpoints.py create mode 100644 src/newsreader/news/core/filters.py create mode 100644 src/newsreader/news/core/migrations/0003_post_read.py create mode 100644 src/newsreader/news/core/templates/core/main.html rename src/newsreader/{static/src => }/scss/accounts/components/form/_form.scss (94%) rename src/newsreader/{static/src => }/scss/accounts/components/form/index.scss (100%) rename src/newsreader/{static/src => }/scss/accounts/components/index.scss (100%) rename src/newsreader/{static/src => }/scss/accounts/components/main/_main.scss (100%) rename src/newsreader/{static/src => }/scss/accounts/components/main/index.scss (100%) rename src/newsreader/{static/src => }/scss/accounts/index.scss (72%) rename src/newsreader/{static/src => }/scss/components/body/_body.scss (61%) rename src/newsreader/{static/src => }/scss/components/body/index.scss (100%) rename src/newsreader/{static/src => }/scss/components/button/_button.scss (95%) rename src/newsreader/{static/src => }/scss/components/button/index.scss (100%) rename src/newsreader/{static/src => }/scss/components/error/_error.scss (100%) rename src/newsreader/{static/src => }/scss/components/error/_errorlist.scss (100%) rename src/newsreader/{static/src => }/scss/components/error/index.scss (100%) rename src/newsreader/{static/src => }/scss/components/form/_form.scss (100%) rename src/newsreader/{static/src => }/scss/components/form/index.scss (100%) rename src/newsreader/{static/src => }/scss/components/index.scss (70%) rename src/newsreader/{static/src => }/scss/components/input/input.scss (100%) create mode 100644 src/newsreader/scss/components/loading-indicator/_loading-indicator.scss create mode 100644 src/newsreader/scss/components/loading-indicator/index.scss rename src/newsreader/{static/src => }/scss/components/main/_main.scss (100%) rename src/newsreader/{static/src => }/scss/components/main/index.scss (100%) create mode 100644 src/newsreader/scss/components/modal/_modal.scss create mode 100644 src/newsreader/scss/components/modal/index.scss rename src/newsreader/{static/src => }/scss/components/navbar/_navbar.scss (96%) rename src/newsreader/{static/src => }/scss/components/navbar/index.scss (100%) create mode 100644 src/newsreader/scss/homepage/components/categories/_categories.scss create mode 100644 src/newsreader/scss/homepage/components/categories/index.scss create mode 100644 src/newsreader/scss/homepage/components/category/_category.scss create mode 100644 src/newsreader/scss/homepage/components/category/index.scss create mode 100644 src/newsreader/scss/homepage/components/content/_content.scss create mode 100644 src/newsreader/scss/homepage/components/content/index.scss create mode 100644 src/newsreader/scss/homepage/components/index.scss create mode 100644 src/newsreader/scss/homepage/components/main/_main.scss create mode 100644 src/newsreader/scss/homepage/components/main/index.scss create mode 100644 src/newsreader/scss/homepage/components/post-block/_post-block.scss create mode 100644 src/newsreader/scss/homepage/components/post-block/index.scss create mode 100644 src/newsreader/scss/homepage/components/post-message/_post-message.scss create mode 100644 src/newsreader/scss/homepage/components/post-message/index.scss create mode 100644 src/newsreader/scss/homepage/components/post/_post.scss create mode 100644 src/newsreader/scss/homepage/components/post/index.scss create mode 100644 src/newsreader/scss/homepage/components/posts-header/_posts-header.scss create mode 100644 src/newsreader/scss/homepage/components/posts-header/index.scss create mode 100644 src/newsreader/scss/homepage/components/posts-section/index.scss create mode 100644 src/newsreader/scss/homepage/components/posts/_posts.scss create mode 100644 src/newsreader/scss/homepage/components/posts/index.scss create mode 100644 src/newsreader/scss/homepage/components/read-button/_read-button.scss create mode 100644 src/newsreader/scss/homepage/components/read-button/index.scss create mode 100644 src/newsreader/scss/homepage/components/rule/_rule.scss create mode 100644 src/newsreader/scss/homepage/components/rule/index.scss create mode 100644 src/newsreader/scss/homepage/components/rules/_rules.scss create mode 100644 src/newsreader/scss/homepage/components/rules/index.scss create mode 100644 src/newsreader/scss/homepage/components/sidebar/_sidebar.scss create mode 100644 src/newsreader/scss/homepage/components/sidebar/index.scss create mode 100644 src/newsreader/scss/homepage/elements/badge/_badge.scss create mode 100644 src/newsreader/scss/homepage/elements/badge/index.scss create mode 100644 src/newsreader/scss/homepage/elements/index.scss create mode 100644 src/newsreader/scss/homepage/index.scss create mode 100644 src/newsreader/scss/partials/_variables.scss create mode 100644 src/newsreader/static/icons/angle-down.svg create mode 100644 src/newsreader/static/icons/angle-right.svg create mode 100644 src/newsreader/static/icons/arrow-left.svg create mode 100644 src/newsreader/static/icons/chevron-down.svg create mode 100644 src/newsreader/static/icons/chevron-right.svg create mode 100644 src/newsreader/static/icons/link.svg create mode 100644 src/newsreader/static/icons/times.svg delete mode 100644 src/newsreader/static/src/scss/partials/_variables.scss diff --git a/.babelrc b/.babelrc index 1320b9a..610dee0 100644 --- a/.babelrc +++ b/.babelrc @@ -1,3 +1,11 @@ { - "presets": ["@babel/preset-env"] + "presets": ["@babel/preset-env"], + "plugins": [ + "@babel/plugin-transform-runtime", + "@babel/plugin-syntax-dynamic-import", + "@babel/plugin-transform-react-jsx", + "@babel/plugin-syntax-function-bind", + "@babel/plugin-proposal-function-bind", + ["@babel/plugin-proposal-class-properties", {loose: true}], + ] } diff --git a/.gitignore b/.gitignore index 0cc2b5c..a5aa72e 100644 --- a/.gitignore +++ b/.gitignore @@ -154,6 +154,7 @@ dmypy.json # Translations # Django stuff: +src/newsreader/fixtures/local # Flask stuff: diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 11fc181..c4ac909 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,13 +1,57 @@ -services: - - postgres:9.6 +stages: + - build + - test + - lint -variables: - POSTGRES_DB: newsreader - POSTGRES_USER: newsreader +javascript build: + image: node:12 + stage: build + cache: + key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG" + paths: + - node_modules/ + before_script: + - npm install --dev + script: + - npx gulp python tests: + services: + - postgres:11 image: python:3.7.4-slim-stretch stage: test + variables: + PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" + DJANGO_SETTINGS_MODULE: "newsreader.conf.gitlab" + POSTGRES_DB: newsreader + POSTGRES_USER: newsreader + cache: + key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG" + paths: + - .cache/pip + - env/ + before_script: + - python3 -m venv env + - source env/bin/activate + - pip install -r requirements/gitlab.txt + script: + - python src/manage.py test newsreader + +javascript linting: + image: node:12 + stage: lint + cache: + key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG" + paths: + - node_modules/ + before_script: + - npm install --dev + script: + - npm run lint + +python linting: + image: python:3.7.4-slim-stretch + stage: lint variables: PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" DJANGO_SETTINGS_MODULE: "newsreader.conf.gitlab" @@ -21,6 +65,6 @@ python tests: - source env/bin/activate - pip install -r requirements/gitlab.txt script: - - python src/manage.py test newsreader - isort -rc src/ --check-only - black -l 90 --check src/ + - autoflake -rc src/ diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..146a217 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,10 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 90, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "arrowParens": "avoid" +} diff --git a/Dockerfile b/Dockerfile index bbdfe24..ac7ac5b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,18 @@ FROM python:3.7-buster +# Run project binaries from the user's local bin folder +ENV PATH=/home/newsreader/.local/bin:$PATH + +# Set the default shell +RUN useradd -ms /bin/bash newsreader + RUN mkdir /app WORKDIR /app +RUN chown newsreader:newsreader /app +USER newsreader # Use a seperate layer for the project requirements -COPY ./requirements /app/requirements -RUN pip install -r requirements/dev.txt +COPY requirements /app/requirements +RUN pip install --user -r requirements/dev.txt COPY . /app/ - -# Set the default shell & add a home dir -RUN useradd -ms /bin/bash newsreader -USER newsreader diff --git a/docker-compose.yml b/docker-compose.yml index 182821d..7987022 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,11 +4,26 @@ services: db: # See https://hub.docker.com/_/postgres image: postgres + container_name: postgres environment: - POSTGRES_USER=newsreader - POSTGRES_DB=newsreader + rabbitmq: + image: rabbitmq:3.7 + container_name: rabbitmq + celery: + build: . + container_name: celery + command: celery -A newsreader worker --beat --scheduler django --workdir=/app/src/ + environment: + - DJANGO_SETTINGS_MODULE=newsreader.conf.docker + volumes: + - .:/app + depends_on: + - rabbitmq web: build: . + container_name: web command: src/entrypoint.sh environment: - DJANGO_SETTINGS_MODULE=newsreader.conf.docker @@ -18,14 +33,3 @@ services: - '8000:8000' depends_on: - db - rabbitmq: - image: rabbitmq:3.7 - celery: - build: . - command: celery -A newsreader worker --beat --scheduler django --loglevel=info --workdir=/app/src/ - environment: - - DJANGO_SETTINGS_MODULE=newsreader.conf.docker - volumes: - - .:/app - depends_on: - - rabbitmq diff --git a/gulp/babel.js b/gulp/babel.js new file mode 100644 index 0000000..8bff9fc --- /dev/null +++ b/gulp/babel.js @@ -0,0 +1,28 @@ +import path from 'path'; + +import { dest } from 'gulp'; +import babelify from 'babelify'; +import browserify from 'browserify'; +import source from 'vinyl-source-stream'; +import buffer from 'vinyl-buffer'; +import concat from 'gulp-concat'; + +const PROJECT_DIR = path.join('src', 'newsreader'); +const STATIC_DIR = path.join(PROJECT_DIR, 'js'); +const CORE_DIR = path.join(PROJECT_DIR, 'news', 'core', 'static'); + +const babelTask = () => { + const config = browserify({ + entries: `${STATIC_DIR}/homepage/index.js`, + debug: true, + }).transform(babelify); + + return config + .bundle() + .pipe(source('index.js')) + .pipe(buffer()) + .pipe(concat('homepage.js')) + .pipe(dest(`${CORE_DIR}/core/dist/js/`)); +}; + +export default babelTask; diff --git a/gulp/sass.js b/gulp/sass.js index 637817f..58cf4b0 100644 --- a/gulp/sass.js +++ b/gulp/sass.js @@ -1,17 +1,25 @@ -import { src, dest } from "gulp"; +import { src, dest } from 'gulp'; -import concat from "gulp-concat"; -import path from "path"; -import sass from "gulp-sass"; +import concat from 'gulp-concat'; +import path from 'path'; +import sass from 'gulp-sass'; -const PROJECT_DIR = path.join("src", "newsreader"); -const STATIC_DIR = path.join(PROJECT_DIR, "static"); +const PROJECT_DIR = path.join('src', 'newsreader'); +const STATIC_DIR = path.join(PROJECT_DIR, 'scss'); -export const ACCOUNTS_DIR = path.join(PROJECT_DIR, "accounts", "static"); +export const ACCOUNTS_DIR = path.join(PROJECT_DIR, 'accounts', 'static'); +export const CORE_DIR = path.join(PROJECT_DIR, 'news', 'core', 'static'); -export default function accountsTask(){ - return src(`${STATIC_DIR}/src/scss/accounts/index.scss`) - .pipe(sass().on("error", sass.logError)) - .pipe(concat("accounts.css")) +export const accountsTask = () => { + return src(`${STATIC_DIR}/accounts/index.scss`) + .pipe(sass().on('error', sass.logError)) + .pipe(concat('accounts.css')) .pipe(dest(`${ACCOUNTS_DIR}/accounts/dist/css`)); }; + +export const coreTask = () => { + return src(`${STATIC_DIR}/homepage/index.scss`) + .pipe(sass().on('error', sass.logError)) + .pipe(concat('core.css')) + .pipe(dest(`${CORE_DIR}/core/dist/css`)); +}; diff --git a/gulpfile.babel.js b/gulpfile.babel.js index 56a18ee..bea1785 100644 --- a/gulpfile.babel.js +++ b/gulpfile.babel.js @@ -1,22 +1,27 @@ -import { series, watch as _watch } from 'gulp'; +import { parallel, series, watch as _watch } from 'gulp'; -import path from "path"; -import del from "del"; +import path from 'path'; +import del from 'del'; -import buildSass, { ACCOUNTS_DIR } from "./gulp/sass"; +import { ACCOUNTS_DIR, CORE_DIR, accountsTask, coreTask } from './gulp/sass'; +import babelTask from './gulp/babel'; -const STATIC_DIR = path.join("src", "newsreader", "static"); +const PROJECT_DIR = path.join('src', 'newsreader'); +const sassTasks = [accountsTask, coreTask]; -function clean(){ +const clean = () => { return del([ `${ACCOUNTS_DIR}/accounts/dist/css/*`, + + `${CORE_DIR}/core/dist/css/*`, + `${CORE_DIR}/core/dist/js/*`, ]); }; -export function watch(){ - _watch(`${STATIC_DIR}/src/scss/**/*.scss`, (done) => { - series(clean, buildSass)(done); +export const watch = () => { + return _watch([`${PROJECT_DIR}/scss/**/*.scss`, `${PROJECT_DIR}/js/**/*.js`], done => { + series(clean, ...sassTasks, babelTask)(done); }); }; -export default series(clean, buildSass); +export default series(clean, ...sassTasks, babelTask); diff --git a/package-lock.json b/package-lock.json index c412ebf..2b98192 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,6 +84,16 @@ "@babel/types": "^7.0.0" } }, + "@babel/helper-builder-react-jsx": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-react-jsx/-/helper-builder-react-jsx-7.3.0.tgz", + "integrity": "sha512-MjA9KgwCuPEkQd9ncSXvSyJ5y+j2sICHyrI0M3L+6fnS4wMSNDc1ARXsbTfbb2cXHn17VisSnU/sHFTCxVxSMw==", + "dev": true, + "requires": { + "@babel/types": "^7.3.0", + "esutils": "^2.0.0" + } + }, "@babel/helper-call-delegate": { "version": "7.4.4", "resolved": "https://registry.npmjs.org/@babel/helper-call-delegate/-/helper-call-delegate-7.4.4.tgz", @@ -95,6 +105,114 @@ "@babel/types": "^7.4.4" } }, + "@babel/helper-create-class-features-plugin": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.5.5.tgz", + "integrity": "sha512-ZsxkyYiRA7Bg+ZTRpPvB6AbOFKTFFK4LrvTet8lInm0V468MWCaSYJE+I7v2z2r8KNLtYiV+K5kTCnR7dvyZjg==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.1.0", + "@babel/helper-member-expression-to-functions": "^7.5.5", + "@babel/helper-optimise-call-expression": "^7.0.0", + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-replace-supers": "^7.5.5", + "@babel/helper-split-export-declaration": "^7.4.4" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.5.5.tgz", + "integrity": "sha512-27d4lZoomVyo51VegxI20xZPuSHusqbQag/ztrBC7wegWoQ1nLREPVSKSW8byhTlzTKyNE4ifaTA6lCp7JjpFw==", + "dev": true, + "requires": { + "@babel/highlight": "^7.0.0" + } + }, + "@babel/generator": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.5.5.tgz", + "integrity": "sha512-ETI/4vyTSxTzGnU2c49XHv2zhExkv9JHLTwDAFz85kmcwuShvYG2H08FwgIguQf4JC75CBnXAUM5PqeF4fj0nQ==", + "dev": true, + "requires": { + "@babel/types": "^7.5.5", + "jsesc": "^2.5.1", + "lodash": "^4.17.13", + "source-map": "^0.5.0", + "trim-right": "^1.0.1" + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.5.5.tgz", + "integrity": "sha512-5qZ3D1uMclSNqYcXqiHoA0meVdv+xUEex9em2fqMnrk/scphGlGgg66zjMrPJESPwrFJ6sbfFQYUSa0Mz7FabA==", + "dev": true, + "requires": { + "@babel/types": "^7.5.5" + } + }, + "@babel/helper-replace-supers": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.5.5.tgz", + "integrity": "sha512-XvRFWrNnlsow2u7jXDuH4jDDctkxbS7gXssrP4q2nUD606ukXHRvydj346wmNg+zAgpFx4MWf4+usfC93bElJg==", + "dev": true, + "requires": { + "@babel/helper-member-expression-to-functions": "^7.5.5", + "@babel/helper-optimise-call-expression": "^7.0.0", + "@babel/traverse": "^7.5.5", + "@babel/types": "^7.5.5" + } + }, + "@babel/parser": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.5.5.tgz", + "integrity": "sha512-E5BN68cqR7dhKan1SfqgPGhQ178bkVKpXTPEXnFJBrEt8/DKRZlybmy+IgYLTeN7tp1R5Ccmbm2rBk17sHYU3g==", + "dev": true + }, + "@babel/traverse": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.5.5.tgz", + "integrity": "sha512-MqB0782whsfffYfSjH4TM+LMjrJnhCNEDMDIjeTpl+ASaUvxcjoiVCo/sM1GhS1pHOXYfWVCYneLjMckuUxDaQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.5.5", + "@babel/generator": "^7.5.5", + "@babel/helper-function-name": "^7.1.0", + "@babel/helper-split-export-declaration": "^7.4.4", + "@babel/parser": "^7.5.5", + "@babel/types": "^7.5.5", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.13" + } + }, + "@babel/types": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.5.5.tgz", + "integrity": "sha512-s63F9nJioLqOlW3UkyMd+BYhXt44YuaFm/VV0VwuteqjYwRrObkU7ra9pY4wAJR3oXi8hJrMcrcJdO/HH33vtw==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, "@babel/helper-define-map": { "version": "7.4.4", "resolved": "https://registry.npmjs.org/@babel/helper-define-map/-/helper-define-map-7.4.4.tgz", @@ -296,6 +414,16 @@ "@babel/plugin-syntax-async-generators": "^7.2.0" } }, + "@babel/plugin-proposal-class-properties": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.5.5.tgz", + "integrity": "sha512-AF79FsnWFxjlaosgdi421vmYG6/jg79bVD0dpD44QdgobzHKuLZ6S3vl8la9qIeSwGi8i1fS0O1mfuDAAdo1/A==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.5.5", + "@babel/helper-plugin-utils": "^7.0.0" + } + }, "@babel/plugin-proposal-dynamic-import": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.5.0.tgz", @@ -306,6 +434,16 @@ "@babel/plugin-syntax-dynamic-import": "^7.2.0" } }, + "@babel/plugin-proposal-function-bind": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-function-bind/-/plugin-proposal-function-bind-7.2.0.tgz", + "integrity": "sha512-qOFJ/eX1Is78sywwTxDcsntLOdb5ZlHVVqUz5xznq8ldAfOVIyZzp1JE2rzHnaksZIhrqMrwIpQL/qcEprnVbw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-function-bind": "^7.2.0" + } + }, "@babel/plugin-proposal-json-strings": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.2.0.tgz", @@ -365,6 +503,15 @@ "@babel/helper-plugin-utils": "^7.0.0" } }, + "@babel/plugin-syntax-function-bind": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-function-bind/-/plugin-syntax-function-bind-7.2.0.tgz", + "integrity": "sha512-/WzU1lLU2l0wDfB42Wkg6tahrmtBbiD8C4H6EGSX0M4GAjzN6JiOpq/Uh8G6GSoR6lPMvhjM0MNiV6znj6y/zg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, "@babel/plugin-syntax-json-strings": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.2.0.tgz", @@ -374,6 +521,15 @@ "@babel/helper-plugin-utils": "^7.0.0" } }, + "@babel/plugin-syntax-jsx": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.2.0.tgz", + "integrity": "sha512-VyN4QANJkRW6lDBmENzRszvZf3/4AXaj9YR7GwrWeeN9tEBPuXbmDYVU9bYBN0D70zCWVwUy0HWq2553VCb6Hw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, "@babel/plugin-syntax-object-rest-spread": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.2.0.tgz", @@ -624,6 +780,17 @@ "@babel/helper-plugin-utils": "^7.0.0" } }, + "@babel/plugin-transform-react-jsx": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.3.0.tgz", + "integrity": "sha512-a/+aRb7R06WcKvQLOu4/TpjKOdvVEKRLWFpKcNuHhiREPgGRB4TQJxq07+EZLS8LFVYpfq1a5lDUnuMdcCpBKg==", + "dev": true, + "requires": { + "@babel/helper-builder-react-jsx": "^7.3.0", + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-jsx": "^7.2.0" + } + }, "@babel/plugin-transform-regenerator": { "version": "7.4.5", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.4.5.tgz", @@ -642,6 +809,18 @@ "@babel/helper-plugin-utils": "^7.0.0" } }, + "@babel/plugin-transform-runtime": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.5.5.tgz", + "integrity": "sha512-6Xmeidsun5rkwnGfMOp6/z9nSzWpHFNVr2Jx7kwoq4mVatQfQx5S56drBgEHF+XQbKOdIaOiMIINvp/kAwMN+w==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.0.0", + "@babel/helper-plugin-utils": "^7.0.0", + "resolve": "^1.8.1", + "semver": "^5.5.1" + } + }, "@babel/plugin-transform-shorthand-properties": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.2.0.tgz", @@ -772,6 +951,14 @@ "source-map-support": "^0.5.9" } }, + "@babel/runtime": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.5.5.tgz", + "integrity": "sha512-28QvEGyQyNkB0/m2B4FU7IEZGK2NUrcMtT6BZEFALTguLk+AUT6ofsHtPk5QyjAdUkpMJ+/Em+quwz4HOt30AQ==", + "requires": { + "regenerator-runtime": "^0.13.2" + } + }, "@babel/template": { "version": "7.4.4", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.4.4.tgz", @@ -883,12 +1070,52 @@ "integrity": "sha512-7TEYTQT1/6PP53NftXXabIZDaZfaoBdeBm8Md/i7zsWRoBe0YwOXguyK8vhHs8ehgB/w9U4K/6EWuTyp0W6nIA==", "dev": true }, + "JSONStream": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", + "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "dev": true, + "requires": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + } + }, "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true }, + "acorn": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.2.1.tgz", + "integrity": "sha512-JD0xT5FCRDNyjDda3Lrg/IxFscp9q4tiYtxE1/nOzlKCk7hIRuYjhq1kCNkbPjMRMZuFq20HNQn1I9k8Oj0E+Q==", + "dev": true + }, + "acorn-dynamic-import": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/acorn-dynamic-import/-/acorn-dynamic-import-4.0.0.tgz", + "integrity": "sha512-d3OEjQV4ROpoflsnUA8HozoIR504TFxNivYEUi6uwz0IYhBkTDXGuWlNdMtybRt3nqVx/L6XqMt0FxkXuWKZhw==", + "dev": true + }, + "acorn-node": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.7.0.tgz", + "integrity": "sha512-XhahLSsCB6X6CJbe+uNu3Mn9sJBNFxtBN9NLgAOQovfS6Kh0lDUtmlclhjn9CvEK7A7YyRU13PXlNcpSiLI9Yw==", + "dev": true, + "requires": { + "acorn": "^6.1.1", + "acorn-dynamic-import": "^4.0.0", + "acorn-walk": "^6.1.1", + "xtend": "^4.0.1" + } + }, + "acorn-walk": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-6.2.0.tgz", + "integrity": "sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA==", + "dev": true + }, "ajv": { "version": "6.10.2", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", @@ -1029,6 +1256,12 @@ "integrity": "sha1-p5SvDAWrF1KEbudTofIRoFugxE8=", "dev": true }, + "array-filter": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/array-filter/-/array-filter-0.0.1.tgz", + "integrity": "sha1-fajPLiZijtcygDWB/SH2fKzS7uw=", + "dev": true + }, "array-find-index": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", @@ -1070,6 +1303,18 @@ } } }, + "array-map": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/array-map/-/array-map-0.0.0.tgz", + "integrity": "sha1-iKK6tz0c97zVwbEYoAP2b2ZfpmI=", + "dev": true + }, + "array-reduce": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/array-reduce/-/array-reduce-0.0.0.tgz", + "integrity": "sha1-FziZ0//Rx9k4PkR5Ul2+J4yrXys=", + "dev": true + }, "array-slice": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz", @@ -1116,6 +1361,44 @@ "safer-buffer": "~2.1.0" } }, + "asn1.js": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", + "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", + "dev": true, + "requires": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "assert": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-1.5.0.tgz", + "integrity": "sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA==", + "dev": true, + "requires": { + "object-assign": "^4.1.1", + "util": "0.10.3" + }, + "dependencies": { + "inherits": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", + "integrity": "sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE=", + "dev": true + }, + "util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", + "integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=", + "dev": true, + "requires": { + "inherits": "2.0.1" + } + } + } + }, "assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -1194,6 +1477,12 @@ "object.assign": "^4.1.0" } }, + "babelify": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/babelify/-/babelify-10.0.0.tgz", + "integrity": "sha512-X40FaxyH7t3X+JFAKvb1H9wooWKLRCi8pg3m8poqtdZaIng+bjzp9RvKQCvRjF9isHiPkXspbbXT/zwXLtwgwg==", + "dev": true + }, "bach": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/bach/-/bach-1.2.0.tgz", @@ -1272,6 +1561,12 @@ } } }, + "base64-js": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz", + "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==", + "dev": true + }, "bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -1287,6 +1582,16 @@ "integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==", "dev": true }, + "bl": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.2.tgz", + "integrity": "sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA==", + "dev": true, + "requires": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, "block-stream": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz", @@ -1296,6 +1601,12 @@ "inherits": "~2.0.0" } }, + "bn.js": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==", + "dev": true + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1335,6 +1646,178 @@ } } }, + "brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=", + "dev": true + }, + "browser-pack": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/browser-pack/-/browser-pack-6.1.0.tgz", + "integrity": "sha512-erYug8XoqzU3IfcU8fUgyHqyOXqIE4tUTTQ+7mqUjQlvnXkOO6OlT9c/ZoJVHYoAaqGxr09CN53G7XIsO4KtWA==", + "dev": true, + "requires": { + "JSONStream": "^1.0.3", + "combine-source-map": "~0.8.0", + "defined": "^1.0.0", + "safe-buffer": "^5.1.1", + "through2": "^2.0.0", + "umd": "^3.0.0" + } + }, + "browser-resolve": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/browser-resolve/-/browser-resolve-1.11.3.tgz", + "integrity": "sha512-exDi1BYWB/6raKHmDTCicQfTkqwN5fioMFV4j8BsfMU4R2DK/QfZfK7kOVkmWCNANf0snkBzqGqAJBao9gZMdQ==", + "dev": true, + "requires": { + "resolve": "1.1.7" + }, + "dependencies": { + "resolve": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz", + "integrity": "sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=", + "dev": true + } + } + }, + "browserify": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/browserify/-/browserify-16.3.0.tgz", + "integrity": "sha512-BWaaD7alyGZVEBBwSTYx4iJF5DswIGzK17o8ai9w4iKRbYpk3EOiprRHMRRA8DCZFmFeOdx7A385w2XdFvxWmg==", + "dev": true, + "requires": { + "JSONStream": "^1.0.3", + "assert": "^1.4.0", + "browser-pack": "^6.0.1", + "browser-resolve": "^1.11.0", + "browserify-zlib": "~0.2.0", + "buffer": "^5.0.2", + "cached-path-relative": "^1.0.0", + "concat-stream": "^1.6.0", + "console-browserify": "^1.1.0", + "constants-browserify": "~1.0.0", + "crypto-browserify": "^3.0.0", + "defined": "^1.0.0", + "deps-sort": "^2.0.0", + "domain-browser": "^1.2.0", + "duplexer2": "~0.1.2", + "events": "^2.0.0", + "glob": "^7.1.0", + "has": "^1.0.0", + "htmlescape": "^1.1.0", + "https-browserify": "^1.0.0", + "inherits": "~2.0.1", + "insert-module-globals": "^7.0.0", + "labeled-stream-splicer": "^2.0.0", + "mkdirp": "^0.5.0", + "module-deps": "^6.0.0", + "os-browserify": "~0.3.0", + "parents": "^1.0.1", + "path-browserify": "~0.0.0", + "process": "~0.11.0", + "punycode": "^1.3.2", + "querystring-es3": "~0.2.0", + "read-only-stream": "^2.0.0", + "readable-stream": "^2.0.2", + "resolve": "^1.1.4", + "shasum": "^1.0.0", + "shell-quote": "^1.6.1", + "stream-browserify": "^2.0.0", + "stream-http": "^2.0.0", + "string_decoder": "^1.1.1", + "subarg": "^1.0.0", + "syntax-error": "^1.1.1", + "through2": "^2.0.0", + "timers-browserify": "^1.0.1", + "tty-browserify": "0.0.1", + "url": "~0.11.0", + "util": "~0.10.1", + "vm-browserify": "^1.0.0", + "xtend": "^4.0.0" + }, + "dependencies": { + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", + "dev": true + } + } + }, + "browserify-aes": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", + "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", + "dev": true, + "requires": { + "buffer-xor": "^1.0.3", + "cipher-base": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.3", + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "browserify-cipher": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", + "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", + "dev": true, + "requires": { + "browserify-aes": "^1.0.4", + "browserify-des": "^1.0.0", + "evp_bytestokey": "^1.0.0" + } + }, + "browserify-des": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", + "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", + "dev": true, + "requires": { + "cipher-base": "^1.0.1", + "des.js": "^1.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "browserify-rsa": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz", + "integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "randombytes": "^2.0.1" + } + }, + "browserify-sign": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.0.4.tgz", + "integrity": "sha1-qk62jl17ZYuqa/alfmMMvXqT0pg=", + "dev": true, + "requires": { + "bn.js": "^4.1.1", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "create-hmac": "^1.1.2", + "elliptic": "^6.0.0", + "inherits": "^2.0.1", + "parse-asn1": "^5.0.0" + } + }, + "browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "dev": true, + "requires": { + "pako": "~1.0.5" + } + }, "browserslist": { "version": "4.6.6", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.6.6.tgz", @@ -1346,6 +1829,16 @@ "node-releases": "^1.1.25" } }, + "buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.2.1.tgz", + "integrity": "sha512-c+Ko0loDaFfuPWiL02ls9Xd3GO3cPVmUobQ6t3rXNUk304u6hGq+8N/kFi+QEIKhzK3uwolVhLzszmfLmMLnqg==", + "dev": true, + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" + } + }, "buffer-equal": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.0.tgz", @@ -1358,6 +1851,18 @@ "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", "dev": true }, + "buffer-xor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", + "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=", + "dev": true + }, + "builtin-status-codes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", + "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=", + "dev": true + }, "cache-base": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", @@ -1375,6 +1880,12 @@ "unset-value": "^1.0.0" } }, + "cached-path-relative": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cached-path-relative/-/cached-path-relative-1.0.2.tgz", + "integrity": "sha512-5r2GqsoEb4qMTTN9J+WzXfjov+hjxT+j3u5K+kIVNIwAd99DLCJE9pBIMP1qVeybV6JiijL385Oz0DcYxfbOIg==", + "dev": true + }, "camelcase": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz", @@ -1450,6 +1961,16 @@ } } }, + "cipher-base": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", + "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "class-utils": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", @@ -1561,6 +2082,26 @@ "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", "dev": true }, + "combine-source-map": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/combine-source-map/-/combine-source-map-0.8.0.tgz", + "integrity": "sha1-pY0N8ELBhvz4IqjoAV9UUNLXmos=", + "dev": true, + "requires": { + "convert-source-map": "~1.1.0", + "inline-source-map": "~0.6.0", + "lodash.memoize": "~3.0.3", + "source-map": "~0.5.3" + }, + "dependencies": { + "convert-source-map": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.1.3.tgz", + "integrity": "sha1-SCnId+n+SbMWHzvzZziI4gRpmGA=", + "dev": true + } + } + }, "combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1617,12 +2158,27 @@ } } }, + "console-browserify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz", + "integrity": "sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA=", + "dev": true, + "requires": { + "date-now": "^0.1.4" + } + }, "console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", "dev": true }, + "constants-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz", + "integrity": "sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U=", + "dev": true + }, "convert-source-map": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.6.0.tgz", @@ -1685,6 +2241,62 @@ "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", "dev": true }, + "create-ecdh": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz", + "integrity": "sha512-GbEHQPMOswGpKXM9kCWVrremUcBmjteUaQ01T9rkKCPDXfUHX0IoP9LpHYo2NPFampa4e+/pFDc3jQdxrxQLaw==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "elliptic": "^6.0.0" + } + }, + "create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "dev": true, + "requires": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "create-hmac": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "dev": true, + "requires": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "crypto-browserify": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz", + "integrity": "sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==", + "dev": true, + "requires": { + "browserify-cipher": "^1.0.0", + "browserify-sign": "^4.0.0", + "create-ecdh": "^4.0.0", + "create-hash": "^1.1.0", + "create-hmac": "^1.1.0", + "diffie-hellman": "^5.0.0", + "inherits": "^2.0.1", + "pbkdf2": "^3.0.3", + "public-encrypt": "^4.0.0", + "randombytes": "^2.0.0", + "randomfill": "^1.0.3" + } + }, "currently-unhandled": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", @@ -1704,6 +2316,12 @@ "type": "^1.0.1" } }, + "dash-ast": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dash-ast/-/dash-ast-1.0.0.tgz", + "integrity": "sha512-Vy4dx7gquTeMcQR/hDkYLGUnwVil6vk4FOOct+djUnHOUWt+zJPJAaRIXaAFkPXtJjvlY7o3rfRu0/3hpnwoUA==", + "dev": true + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -1713,6 +2331,12 @@ "assert-plus": "^1.0.0" } }, + "date-now": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz", + "integrity": "sha1-6vQ5/U1ISK105cx9vvIAZyueNFs=", + "dev": true + }, "debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -1734,6 +2358,11 @@ "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", "dev": true }, + "deep-diff": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-0.3.8.tgz", + "integrity": "sha1-wB3mPvsO7JeYgB1Ax+Da4ltYLIQ=" + }, "default-compare": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/default-compare/-/default-compare-1.0.0.tgz", @@ -1807,6 +2436,12 @@ } } }, + "defined": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz", + "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=", + "dev": true + }, "del": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/del/-/del-5.0.0.tgz", @@ -1832,12 +2467,56 @@ "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", "dev": true }, + "deps-sort": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/deps-sort/-/deps-sort-2.0.0.tgz", + "integrity": "sha1-CRckkC6EZYJg65EHSMzNGvbiH7U=", + "dev": true, + "requires": { + "JSONStream": "^1.0.3", + "shasum": "^1.0.0", + "subarg": "^1.0.0", + "through2": "^2.0.0" + } + }, + "des.js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.0.tgz", + "integrity": "sha1-wHTS4qpqipoH29YfmhXCzYPsjsw=", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, "detect-file": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", "integrity": "sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc=", "dev": true }, + "detective": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz", + "integrity": "sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==", + "dev": true, + "requires": { + "acorn-node": "^1.6.1", + "defined": "^1.0.0", + "minimist": "^1.1.1" + } + }, + "diffie-hellman": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "miller-rabin": "^4.0.0", + "randombytes": "^2.0.0" + } + }, "dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -1855,6 +2534,21 @@ } } }, + "domain-browser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz", + "integrity": "sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==", + "dev": true + }, + "duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=", + "dev": true, + "requires": { + "readable-stream": "^2.0.2" + } + }, "duplexify": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", @@ -1893,6 +2587,21 @@ "integrity": "sha512-jasjtY5RUy/TOyiUYM2fb4BDaPZfm6CXRFeJDMfFsXYADGxUN49RBqtgB7EL2RmJXeIRUk9lM1U6A5yk2YJMPQ==", "dev": true }, + "elliptic": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.0.tgz", + "integrity": "sha512-eFOJTMyCYb7xtE/caJ6JJu+bhi67WCYNbkGSknu20pmM8Ke/bqOfdnZWxyoGN26JgfxTbXrsCkEw4KheCT/KGg==", + "dev": true, + "requires": { + "bn.js": "^4.4.0", + "brorand": "^1.0.1", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.0" + } + }, "end-of-stream": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz", @@ -1967,6 +2676,22 @@ "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=", "dev": true }, + "events": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/events/-/events-2.1.0.tgz", + "integrity": "sha512-3Zmiobend8P9DjmKAty0Era4jV8oJ0yGYe2nJJAxgymF9+N8F2m0hhZiMoWtcfepExzNKZumFU3ksdQbInGWCg==", + "dev": true + }, + "evp_bytestokey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", + "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", + "dev": true, + "requires": { + "md5.js": "^1.3.4", + "safe-buffer": "^5.1.1" + } + }, "expand-brackets": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", @@ -2979,6 +3704,12 @@ "globule": "^1.0.0" } }, + "get-assigned-identifiers": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/get-assigned-identifiers/-/get-assigned-identifiers-1.2.0.tgz", + "integrity": "sha512-mBBwmeGTrxEMO4pMaaf/uUEFHnYtwr8FTe8Y/mer4rcV/bye0qGm6pw1bGZFGStxC5O76c5ZAVBGnqHmOaJpdQ==", + "dev": true + }, "get-caller-file": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", @@ -3264,6 +3995,15 @@ "har-schema": "^2.0.0" } }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, "has-ansi": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", @@ -3331,6 +4071,45 @@ } } }, + "hash-base": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.4.tgz", + "integrity": "sha1-X8hoaEfs1zSZQDMZprCj8/auSRg=", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=", + "dev": true, + "requires": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "hoist-non-react-statics": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.0.tgz", + "integrity": "sha512-0XsbTXxgiaCDYDIWFcwkmerZPSwywfUqYmwT4jzewKTQSWoE6FCMoUVOeBJWK3E/CrWbxRG3m5GzY4lnIwGRBA==", + "requires": { + "react-is": "^16.7.0" + } + }, "homedir-polyfill": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", @@ -3346,6 +4125,12 @@ "integrity": "sha512-7T/BxH19zbcCTa8XkMlbK5lTo1WtgkFi3GvdWEyNuc4Vex7/9Dqbnpsf4JMydcfj9HCg4zUWFTL3Za6lapg5/w==", "dev": true }, + "htmlescape": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/htmlescape/-/htmlescape-1.1.1.tgz", + "integrity": "sha1-OgPtwiFLyjtmQko+eVk0lQnLA1E=", + "dev": true + }, "http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", @@ -3357,6 +4142,18 @@ "sshpk": "^1.7.0" } }, + "https-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", + "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=", + "dev": true + }, + "ieee754": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", + "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==", + "dev": true + }, "ignore": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.2.tgz", @@ -3400,6 +4197,33 @@ "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", "dev": true }, + "inline-source-map": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/inline-source-map/-/inline-source-map-0.6.2.tgz", + "integrity": "sha1-+Tk0ccGKedFyT4Y/o4tYY3Ct4qU=", + "dev": true, + "requires": { + "source-map": "~0.5.3" + } + }, + "insert-module-globals": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/insert-module-globals/-/insert-module-globals-7.2.0.tgz", + "integrity": "sha512-VE6NlW+WGn2/AeOMd496AHFYmE7eLKkUY6Ty31k4og5vmA3Fjuwe9v6ifH6Xx/Hz27QvdoMoviw1/pqWRB09Sw==", + "dev": true, + "requires": { + "JSONStream": "^1.0.3", + "acorn-node": "^1.5.2", + "combine-source-map": "^0.8.0", + "concat-stream": "^1.6.1", + "is-buffer": "^1.1.0", + "path-is-absolute": "^1.0.1", + "process": "~0.11.0", + "through2": "^2.0.0", + "undeclared-identifiers": "^1.1.2", + "xtend": "^4.0.0" + } + }, "interpret": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.2.0.tgz", @@ -3410,7 +4234,6 @@ "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "dev": true, "requires": { "loose-envify": "^1.0.0" } @@ -3678,6 +4501,11 @@ "integrity": "sha512-M7kLczedRMYX4L8Mdh4MzyAMM9O5osx+4FcOQuTvr3A9F2D9S5JXheN0ewNbrvK2UatkTRhL5ejGmGSjNMiZuw==", "dev": true }, + "js-cookie": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz", + "integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==" + }, "js-levenshtein": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", @@ -3687,8 +4515,7 @@ "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "jsbn": { "version": "0.1.1", @@ -3714,6 +4541,15 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, + "json-stable-stringify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-0.0.1.tgz", + "integrity": "sha1-YRwj6BTbN1Un34URk9tZ3Sryf0U=", + "dev": true, + "requires": { + "jsonify": "~0.0.0" + } + }, "json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -3735,6 +4571,18 @@ "minimist": "^1.2.0" } }, + "jsonify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", + "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=", + "dev": true + }, + "jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=", + "dev": true + }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", @@ -3759,6 +4607,16 @@ "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", "dev": true }, + "labeled-stream-splicer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/labeled-stream-splicer/-/labeled-stream-splicer-2.0.2.tgz", + "integrity": "sha512-Ca4LSXFFZUjPScRaqOcFxneA0VpKZr4MMYCljyQr4LIewTLb3Y0IUTIsnBBsVubIeEfxeSZpSjSsRM8APEQaAw==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "stream-splicer": "^2.0.0" + } + }, "last-run": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/last-run/-/last-run-1.1.1.tgz", @@ -3844,10 +4702,9 @@ } }, "lodash": { - "version": "4.17.14", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.14.tgz", - "integrity": "sha512-mmKYbW3GLuJeX+iGP+Y7Gp1AiGHGbXHCOh/jZmrawMmsE7MS4znI3RL2FsjbqOyMayHInjOeykW7PEajUk1/xw==", - "dev": true + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" }, "lodash.clonedeep": { "version": "4.5.0", @@ -3855,11 +4712,16 @@ "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", "dev": true }, + "lodash.memoize": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-3.0.4.tgz", + "integrity": "sha1-LcvSwofLwKVcxCMovQxzYVDVPj8=", + "dev": true + }, "loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "requires": { "js-tokens": "^3.0.0 || ^4.0.0" } @@ -3949,6 +4811,17 @@ } } }, + "md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "dev": true, + "requires": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, "meow": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz", @@ -3994,6 +4867,16 @@ "to-regex": "^3.0.2" } }, + "miller-rabin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", + "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", + "dev": true, + "requires": { + "bn.js": "^4.0.0", + "brorand": "^1.0.1" + } + }, "mime-db": { "version": "1.40.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz", @@ -4009,6 +4892,18 @@ "mime-db": "1.40.0" } }, + "minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true + }, + "minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=", + "dev": true + }, "minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", @@ -4062,6 +4957,29 @@ } } }, + "module-deps": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/module-deps/-/module-deps-6.2.1.tgz", + "integrity": "sha512-UnEn6Ah36Tu4jFiBbJVUtt0h+iXqxpLqDvPS8nllbw5RZFmNJ1+Mz5BjYnM9ieH80zyxHkARGLnMIHlPK5bu6A==", + "dev": true, + "requires": { + "JSONStream": "^1.0.3", + "browser-resolve": "^1.7.0", + "cached-path-relative": "^1.0.2", + "concat-stream": "~1.6.0", + "defined": "^1.0.0", + "detective": "^5.0.2", + "duplexer2": "^0.1.2", + "inherits": "^2.0.1", + "parents": "^1.0.0", + "readable-stream": "^2.0.2", + "resolve": "^1.4.0", + "stream-combiner2": "^1.1.1", + "subarg": "^1.0.0", + "through2": "^2.0.0", + "xtend": "^4.0.0" + } + }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -4307,8 +5225,7 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", - "dev": true + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, "object-copy": { "version": "0.1.0", @@ -4427,6 +5344,12 @@ "readable-stream": "^2.0.1" } }, + "os-browserify": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", + "integrity": "sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=", + "dev": true + }, "os-homedir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", @@ -4488,6 +5411,35 @@ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "dev": true }, + "pako": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.10.tgz", + "integrity": "sha512-0DTvPVU3ed8+HNXOu5Bs+o//Mbdj9VNQMUOe9oKCwh8l0GNwpTDMKCWbRjgtD291AWnkAgkqA/LOnQS8AmS1tw==", + "dev": true + }, + "parents": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parents/-/parents-1.0.1.tgz", + "integrity": "sha1-/t1NK/GTp3dF/nHjcdc8MwfZx1E=", + "dev": true, + "requires": { + "path-platform": "~0.11.15" + } + }, + "parse-asn1": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.4.tgz", + "integrity": "sha512-Qs5duJcuvNExRfFZ99HDD3z4mAi3r9Wl/FOjEOijlxwCZs7E7mW2vjTpgQ4J8LpTF8x5v+1Vn5UQFejmWT11aw==", + "dev": true, + "requires": { + "asn1.js": "^4.0.0", + "browserify-aes": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.0", + "pbkdf2": "^3.0.3", + "safe-buffer": "^5.1.1" + } + }, "parse-filepath": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz", @@ -4526,6 +5478,12 @@ "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=", "dev": true }, + "path-browserify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz", + "integrity": "sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ==", + "dev": true + }, "path-dirname": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", @@ -4556,6 +5514,12 @@ "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", "dev": true }, + "path-platform": { + "version": "0.11.15", + "resolved": "https://registry.npmjs.org/path-platform/-/path-platform-0.11.15.tgz", + "integrity": "sha1-6GQhf3TDaFDwhSt43Hv31KVyG/I=", + "dev": true + }, "path-root": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz", @@ -4590,6 +5554,19 @@ } } }, + "pbkdf2": { + "version": "3.0.17", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.0.17.tgz", + "integrity": "sha512-U/il5MsrZp7mGg3mSQfn742na2T+1/vHDCG5/iTI3X9MKUuYUZVLQhyRsg06mCgDBTd57TxzgZt7P+fYfjRLtA==", + "dev": true, + "requires": { + "create-hash": "^1.1.2", + "create-hmac": "^1.1.4", + "ripemd160": "^2.0.1", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -4677,12 +5654,28 @@ "integrity": "sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==", "dev": true }, + "process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=", + "dev": true + }, "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "prop-types": { + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" + } + }, "pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", @@ -4695,6 +5688,20 @@ "integrity": "sha512-GEn74ZffufCmkDDLNcl3uuyF/aSD6exEyh1v/ZSdAomB82t6G9hzJVRx0jBmLDW+VfZqks3aScmMw9DszwUalA==", "dev": true }, + "public-encrypt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", + "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "parse-asn1": "^5.0.0", + "randombytes": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, "pump": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz", @@ -4728,6 +5735,88 @@ "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", "dev": true }, + "querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=", + "dev": true + }, + "querystring-es3": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz", + "integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=", + "dev": true + }, + "randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.0" + } + }, + "randomfill": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", + "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", + "dev": true, + "requires": { + "randombytes": "^2.0.5", + "safe-buffer": "^5.1.0" + } + }, + "react": { + "version": "16.8.6", + "resolved": "https://registry.npmjs.org/react/-/react-16.8.6.tgz", + "integrity": "sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw==", + "dev": true, + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.13.6" + } + }, + "react-dom": { + "version": "16.8.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.8.6.tgz", + "integrity": "sha512-1nL7PIq9LTL3fthPqwkvr2zY7phIPjYrT0jp4HjyEQrEROnw4dG41VVwi/wfoCneoleqrNX7iAD+pXebJZwrwA==", + "dev": true, + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.13.6" + } + }, + "react-is": { + "version": "16.8.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz", + "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==" + }, + "react-redux": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.1.0.tgz", + "integrity": "sha512-hyu/PoFK3vZgdLTg9ozbt7WF3GgX5+Yn3pZm5/96/o4UueXA+zj08aiSC9Mfj2WtD1bvpIb3C5yvskzZySzzaw==", + "requires": { + "@babel/runtime": "^7.4.5", + "hoist-non-react-statics": "^3.3.0", + "invariant": "^2.2.4", + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^16.8.6" + } + }, + "read-only-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-only-stream/-/read-only-stream-2.0.0.tgz", + "integrity": "sha1-JyT9aoET1zdkrCiNQ4YnDB2/F/A=", + "dev": true, + "requires": { + "readable-stream": "^2.0.2" + } + }, "read-pkg": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", @@ -4815,6 +5904,28 @@ "strip-indent": "^1.0.1" } }, + "redux": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.0.4.tgz", + "integrity": "sha512-vKv4WdiJxOWKxK0yRoaK3Y4pxxB0ilzVx6dszU2W8wLxlb2yikRph4iV/ymtdJ6ZxpBLFbyrxklnT5yBbQSl3Q==", + "requires": { + "loose-envify": "^1.4.0", + "symbol-observable": "^1.2.0" + } + }, + "redux-logger": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/redux-logger/-/redux-logger-3.0.6.tgz", + "integrity": "sha1-91VZZvMJjzyIYExEnPC69XeCdL8=", + "requires": { + "deep-diff": "^0.3.5" + } + }, + "redux-thunk": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.3.0.tgz", + "integrity": "sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw==" + }, "regenerate": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.0.tgz", @@ -4830,6 +5941,11 @@ "regenerate": "^1.4.0" } }, + "regenerator-runtime": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz", + "integrity": "sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw==" + }, "regenerator-transform": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.14.0.tgz", @@ -5052,6 +6168,16 @@ "glob": "^7.1.3" } }, + "ripemd160": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", + "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", + "dev": true, + "requires": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1" + } + }, "run-parallel": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.1.9.tgz", @@ -5233,6 +6359,16 @@ } } }, + "scheduler": { + "version": "0.13.6", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.6.tgz", + "integrity": "sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ==", + "dev": true, + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, "scss-tokenizer": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz", @@ -5298,12 +6434,50 @@ } } }, + "sha.js": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", + "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "shasum": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/shasum/-/shasum-1.0.2.tgz", + "integrity": "sha1-5wEjENj0F/TetXEhUOVni4euVl8=", + "dev": true, + "requires": { + "json-stable-stringify": "~0.0.0", + "sha.js": "~2.4.4" + } + }, + "shell-quote": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.6.1.tgz", + "integrity": "sha1-9HgZSczkAmlxJ0MOo7PFR29IF2c=", + "dev": true, + "requires": { + "array-filter": "~0.0.0", + "array-map": "~0.0.0", + "array-reduce": "~0.0.0", + "jsonify": "~0.0.0" + } + }, "signal-exit": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", "dev": true }, + "simple-concat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.0.tgz", + "integrity": "sha1-c0TLuLbib7J9ZrL8hvn21Zl1IcY=", + "dev": true + }, "slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -5560,18 +6734,61 @@ "readable-stream": "^2.0.1" } }, + "stream-browserify": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz", + "integrity": "sha512-nX6hmklHs/gr2FuxYDltq8fJA1GDlxKQCz8O/IM4atRqBH8OORmBNgfvW5gG10GT/qQ9u0CzIvr2X5Pkt6ntqg==", + "dev": true, + "requires": { + "inherits": "~2.0.1", + "readable-stream": "^2.0.2" + } + }, + "stream-combiner2": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", + "integrity": "sha1-+02KFCDqNidk4hrUeAOXvry0HL4=", + "dev": true, + "requires": { + "duplexer2": "~0.1.0", + "readable-stream": "^2.0.2" + } + }, "stream-exhaust": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/stream-exhaust/-/stream-exhaust-1.0.2.tgz", "integrity": "sha512-b/qaq/GlBK5xaq1yrK9/zFcyRSTNxmcZwFLGSTG0mXgZl/4Z6GgiyYOXOvY7N3eEvFRAG1bkDRz5EPGSvPYQlw==", "dev": true }, + "stream-http": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-2.8.3.tgz", + "integrity": "sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw==", + "dev": true, + "requires": { + "builtin-status-codes": "^3.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.3.6", + "to-arraybuffer": "^1.0.0", + "xtend": "^4.0.0" + } + }, "stream-shift": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.0.tgz", "integrity": "sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI=", "dev": true }, + "stream-splicer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/stream-splicer/-/stream-splicer-2.0.1.tgz", + "integrity": "sha512-Xizh4/NPuYSyAXyT7g8IvdJ9HJpxIGL9PjyhtywCZvvP0OPIdqyrr4dMikeuvY8xahpdKEBlBTySe583totajg==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "readable-stream": "^2.0.2" + } + }, "string-width": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", @@ -5630,6 +6847,15 @@ "get-stdin": "^4.0.1" } }, + "subarg": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/subarg/-/subarg-1.0.0.tgz", + "integrity": "sha1-9izxdYHplrSPyWVpn1TAauJouNI=", + "dev": true, + "requires": { + "minimist": "^1.1.0" + } + }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -5649,6 +6875,20 @@ "es6-symbol": "^3.1.1" } }, + "symbol-observable": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==" + }, + "syntax-error": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/syntax-error/-/syntax-error-1.4.0.tgz", + "integrity": "sha512-YPPlu67mdnHGTup2A8ff7BC2Pjq0e0Yp/IyTFN03zWO0RcK07uLcbi7C2KpGR2FvWbaB0+bfE27a+sBKebSo7w==", + "dev": true, + "requires": { + "acorn-node": "^1.2.0" + } + }, "tar": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.2.tgz", @@ -5660,6 +6900,12 @@ "inherits": "2" } }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", + "dev": true + }, "through2": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", @@ -5686,6 +6932,15 @@ "integrity": "sha1-dkpaEa9QVhkhsTPztE5hhofg9cM=", "dev": true }, + "timers-browserify": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-1.4.2.tgz", + "integrity": "sha1-ycWLV1voQHN1y14kYtrO50NZ9B0=", + "dev": true, + "requires": { + "process": "~0.11.0" + } + }, "to-absolute-glob": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz", @@ -5696,6 +6951,12 @@ "is-negated-glob": "^1.0.0" } }, + "to-arraybuffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", + "integrity": "sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M=", + "dev": true + }, "to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -5792,6 +7053,12 @@ "glob": "^7.1.2" } }, + "tty-browserify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.1.tgz", + "integrity": "sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==", + "dev": true + }, "tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -5819,12 +7086,31 @@ "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", "dev": true }, + "umd": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/umd/-/umd-3.0.3.tgz", + "integrity": "sha512-4IcGSufhFshvLNcMCV80UnQVlZ5pMOC8mvNPForqwA4+lzYQuetTESLDQkeLmihq8bRcnpbQa48Wb8Lh16/xow==", + "dev": true + }, "unc-path-regex": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", "integrity": "sha1-5z3T17DXxe2G+6xrCufYxqadUPo=", "dev": true }, + "undeclared-identifiers": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/undeclared-identifiers/-/undeclared-identifiers-1.1.3.tgz", + "integrity": "sha512-pJOW4nxjlmfwKApE4zvxLScM/njmwj/DiUBv7EabwE4O8kRUy+HIwxQtZLBPll/jx1LJyBcqNfB3/cpv9EZwOw==", + "dev": true, + "requires": { + "acorn-node": "^1.3.0", + "dash-ast": "^1.0.0", + "get-assigned-identifiers": "^1.2.0", + "simple-concat": "^1.0.0", + "xtend": "^4.0.1" + } + }, "undertaker": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/undertaker/-/undertaker-1.2.1.tgz", @@ -5959,12 +7245,47 @@ "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=", "dev": true }, + "url": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz", + "integrity": "sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE=", + "dev": true, + "requires": { + "punycode": "1.3.2", + "querystring": "0.2.0" + }, + "dependencies": { + "punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=", + "dev": true + } + } + }, "use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", "dev": true }, + "util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "dev": true, + "requires": { + "inherits": "2.0.3" + }, + "dependencies": { + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true + } + } + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -6027,6 +7348,16 @@ "replace-ext": "^1.0.0" } }, + "vinyl-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vinyl-buffer/-/vinyl-buffer-1.0.1.tgz", + "integrity": "sha1-lsGjR5uMU5JULGEgKQE7Wyf4i78=", + "dev": true, + "requires": { + "bl": "^1.2.1", + "through2": "^2.0.3" + } + }, "vinyl-fs": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-3.0.3.tgz", @@ -6052,6 +7383,16 @@ "vinyl-sourcemap": "^1.1.0" } }, + "vinyl-source-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/vinyl-source-stream/-/vinyl-source-stream-2.0.0.tgz", + "integrity": "sha1-84pa+53R6Ttl1VBGmsYYKsT1S44=", + "dev": true, + "requires": { + "through2": "^2.0.3", + "vinyl": "^2.1.0" + } + }, "vinyl-sourcemap": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/vinyl-sourcemap/-/vinyl-sourcemap-1.1.0.tgz", @@ -6076,6 +7417,12 @@ "source-map": "^0.5.1" } }, + "vm-browserify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.0.tgz", + "integrity": "sha512-iq+S7vZJE60yejDYM0ek6zg308+UZsdtPExWP9VZoCFCz1zkJoXFnAX7aZfd/ZwrkidzdUZL0C/ryW+JwAiIGw==", + "dev": true + }, "which": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", diff --git a/package.json b/package.json index 5e339a4..5eca7dd 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "description": "Application for viewing RSS feeds", "main": "index.js", "scripts": { - "lint": "prettier \"src/newsreader/**/*.js\" --check", - "format": "prettier \"src/newsreader/**/*.js\" --write" + "lint": "prettier \"src/newsreader/js/**/*.js\" --check", + "format": "prettier \"src/newsreader/js/**/*.js\" --write" }, "repository": { "type": "git", @@ -13,21 +13,27 @@ }, "author": "Sonny", "license": "GPL-3.0-or-later", - "prettier": { - "semi": true, - "trailingComma": "es5", - "singleQuote": false, - "printWidth": 80, - "tabWidth": 2, - "useTabs": false, - "bracketSpacing": false, - "arrowParens": "always" + "dependencies": { + "js-cookie": "^2.2.1", + "lodash": "^4.17.15", + "react-redux": "^7.1.0", + "redux": "^4.0.4", + "redux-logger": "^3.0.6", + "redux-thunk": "^2.3.0" }, - "dependencies": {}, "devDependencies": { "@babel/core": "^7.5.4", + "@babel/plugin-proposal-class-properties": "^7.5.5", + "@babel/plugin-proposal-function-bind": "^7.2.0", + "@babel/plugin-syntax-dynamic-import": "^7.2.0", + "@babel/plugin-syntax-function-bind": "^7.2.0", + "@babel/plugin-transform-react-jsx": "^7.3.0", + "@babel/plugin-transform-runtime": "^7.5.5", "@babel/preset-env": "^7.5.4", "@babel/register": "^7.4.4", + "@babel/runtime": "^7.5.5", + "babelify": "^10.0.0", + "browserify": "^16.3.0", "del": "^5.0.0", "gulp": "^4.0.2", "gulp-babel": "^8.0.0-beta.2", @@ -35,6 +41,10 @@ "gulp-concat": "^2.6.1", "gulp-sass": "^4.0.2", "node-sass": "^4.12.0", - "prettier": "^1.18.2" + "prettier": "^1.18.2", + "react": "^16.8.6", + "react-dom": "^16.8.6", + "vinyl-buffer": "^1.0.1", + "vinyl-source-stream": "^2.0.0" } } diff --git a/requirements/base.txt b/requirements/base.txt index 25bf4ff..b3bcaf7 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -6,7 +6,7 @@ chardet==3.0.4 Django==2.2 django-celery-beat==1.5.0 djangorestframework==3.9.4 -django-rest-swagger-2.2.0 +django-rest-swagger==2.2.0 lxml==4.3.4 feedparser==5.2.1 idna==2.8 diff --git a/requirements/gitlab.txt b/requirements/gitlab.txt index a0b3eca..5e3e231 100644 --- a/requirements/gitlab.txt +++ b/requirements/gitlab.txt @@ -1,6 +1,5 @@ --r base.txt +-r testing.txt -factory-boy==2.12.0 -freezegun==0.3.12 black==19.3b0 isort==4.3.20 +autoflake==1.3 diff --git a/src/newsreader/accounts/views.py b/src/newsreader/accounts/views.py index ae33591..4957350 100644 --- a/src/newsreader/accounts/views.py +++ b/src/newsreader/accounts/views.py @@ -3,13 +3,11 @@ from django.contrib.auth.views import LogoutView as DjangoLogoutView from django.urls import reverse_lazy -# TODO redirect to homepage when logged in class LoginView(DjangoLoginView): template_name = "accounts/login.html" def get_success_url(self): - # TODO redirect to homepage - return reverse_lazy("admin:index") + return reverse_lazy("index") class LogoutView(DjangoLogoutView): diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 6d5e1b0..e8bcc68 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -112,6 +112,8 @@ USE_TZ = True # https://docs.djangoproject.com/en/2.2/howto/static-files/ STATIC_URL = "/static/" +STATICFILES_DIRS = ["src/newsreader/static/icons"] + # Third party settings REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": ( @@ -123,3 +125,9 @@ REST_FRAMEWORK = { ), "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), } + +SWAGGER_SETTINGS = { + "LOGIN_URL": "rest_framework:login", + "LOGOUT_URL": "rest_framework:logout", + "DOC_EXPANSION": "list", +} diff --git a/src/newsreader/fixtures/default-fixture.json b/src/newsreader/fixtures/default-fixture.json index 514930a..e0ed80f 100644 --- a/src/newsreader/fixtures/default-fixture.json +++ b/src/newsreader/fixtures/default-fixture.json @@ -1 +1,282 @@ -[{"model": "accounts.user", "pk": 1, "fields": {"password": "pbkdf2_sha256$150000$OeNoz2LRSpI5$jkkUf/BjTuWZULyldvNTt9f45/ErxaCmCQHfZwtzji8=", "last_login": "2019-08-10T18:07:27.224Z", "is_superuser": true, "first_name": "", "last_name": "", "is_staff": true, "is_active": true, "date_joined": "2019-08-10T18:07:19.699Z", "email": "sonny@newsreader.nl", "task": null, "task_interval": null, "groups": [], "user_permissions": []}}, {"model": "django_celery_beat.intervalschedule", "pk": 1, "fields": {"every": 5, "period": "minutes"}}, {"model": "django_celery_beat.intervalschedule", "pk": 2, "fields": {"every": 30, "period": "minutes"}}, {"model": "django_celery_beat.intervalschedule", "pk": 3, "fields": {"every": 3, "period": "hours"}}, {"model": "django_celery_beat.crontabschedule", "pk": 1, "fields": {"minute": "0", "hour": "4", "day_of_week": "*", "day_of_month": "*", "month_of_year": "*", "timezone": "UTC"}}, {"model": "django_celery_beat.periodictasks", "pk": 1, "fields": {"last_update": "2019-08-10T20:01:21.152Z"}}, {"model": "django_celery_beat.periodictask", "pk": 1, "fields": {"name": "celery.backend_cleanup", "task": "celery.backend_cleanup", "interval": null, "crontab": 1, "solar": null, "clocked": null, "args": "[]", "kwargs": "{}", "queue": null, "exchange": null, "routing_key": null, "headers": "{}", "priority": null, "expires": null, "one_off": false, "start_time": null, "enabled": true, "last_run_at": null, "total_run_count": 0, "date_changed": "2019-08-10T19:56:33.160Z", "description": ""}}, {"model": "django_celery_beat.periodictask", "pk": 3, "fields": {"name": "Collection testing task", "task": "newsreader.news.collection.tasks.collect", "interval": 3, "crontab": null, "solar": null, "clocked": null, "args": "[1]", "kwargs": "{}", "queue": null, "exchange": null, "routing_key": null, "headers": "{}", "priority": null, "expires": null, "one_off": false, "start_time": "2019-08-10T20:00:52Z", "enabled": true, "last_run_at": null, "total_run_count": 0, "date_changed": "2019-08-10T20:01:21.153Z", "description": ""}}, {"model": "core.category", "pk": 1, "fields": {"created": "2019-08-10T19:57:53Z", "modified": "2019-08-10T19:58:02.048Z", "name": "Tech", "user": 1}}, {"model": "core.category", "pk": 2, "fields": {"created": "2019-08-10T19:58:13Z", "modified": "2019-08-10T19:58:20.951Z", "name": "News", "user": 1}}, {"model": "collection.collectionrule", "pk": 1, "fields": {"created": "2019-08-10T18:08:10.520Z", "modified": "2019-08-10T19:59:54.547Z", "name": "Tweakers", "url": "http://feeds.feedburner.com/tweakers/mixed", "website_url": null, "favicon": null, "timezone": "Europe/Amsterdam", "category": 1, "last_suceeded": "2019-08-10T18:25:32.325Z", "succeeded": true, "error": null, "user": 1}}, {"model": "collection.collectionrule", "pk": 2, "fields": {"created": "2019-08-10T19:58:05.615Z", "modified": "2019-08-10T19:58:05.691Z", "name": "Hackers News", "url": "https://news.ycombinator.com/rss", "website_url": null, "favicon": null, "timezone": "UTC", "category": 1, "last_suceeded": null, "succeeded": false, "error": null, "user": 1}}, {"model": "collection.collectionrule", "pk": 3, "fields": {"created": "2019-08-10T19:58:23.441Z", "modified": "2019-08-10T19:58:23.449Z", "name": "BBC", "url": "http://feeds.bbci.co.uk/news/world/rss.xml", "website_url": null, "favicon": null, "timezone": "UTC", "category": 2, "last_suceeded": null, "succeeded": false, "error": null, "user": 1}}, {"model": "collection.collectionrule", "pk": 4, "fields": {"created": "2019-08-10T19:58:31.867Z", "modified": "2019-08-10T19:58:31.873Z", "name": "Ars Technica", "url": "http://feeds.arstechnica.com/arstechnica/index?fmt=xml", "website_url": null, "favicon": null, "timezone": "UTC", "category": 1, "last_suceeded": null, "succeeded": false, "error": null, "user": 1}}, {"model": "collection.collectionrule", "pk": 5, "fields": {"created": "2019-08-10T19:58:48.529Z", "modified": "2019-08-10T19:58:48.535Z", "name": "The Guardian", "url": "https://www.theguardian.com/world/rss", "website_url": null, "favicon": null, "timezone": "UTC", "category": 2, "last_suceeded": null, "succeeded": false, "error": null, "user": 1}}, {"model": "collection.collectionrule", "pk": 6, "fields": {"created": "2019-08-10T19:58:58.641Z", "modified": "2019-08-10T19:58:58.647Z", "name": "Engadget", "url": "https://www.engadget.com/rss.xml", "website_url": null, "favicon": null, "timezone": "UTC", "category": 1, "last_suceeded": null, "succeeded": false, "error": null, "user": 1}}, {"model": "collection.collectionrule", "pk": 7, "fields": {"created": "2019-08-10T19:59:29.909Z", "modified": "2019-08-10T19:59:29.917Z", "name": "The Verge", "url": "https://www.theverge.com/rss/index.xml", "website_url": null, "favicon": null, "timezone": "UTC", "category": 1, "last_suceeded": null, "succeeded": false, "error": null, "user": 1}}, {"model": "collection.collectionrule", "pk": 8, "fields": {"created": "2019-08-10T19:59:44.833Z", "modified": "2019-08-10T19:59:44.838Z", "name": "News", "url": "http://feeds.boingboing.net/boingboing/iBag", "website_url": null, "favicon": null, "timezone": "UTC", "category": 2, "last_suceeded": null, "succeeded": false, "error": null, "user": 1}}] \ No newline at end of file +[ + { + "fields" : { + "last_login" : "2019-08-10T18:07:27.224Z", + "last_name" : "", + "is_superuser" : true, + "is_staff" : true, + "task_interval" : null, + "email" : "sonny@newsreader.nl", + "user_permissions" : [], + "groups" : [], + "password" : "pbkdf2_sha256$150000$OeNoz2LRSpI5$jkkUf/BjTuWZULyldvNTt9f45/ErxaCmCQHfZwtzji8=", + "first_name" : "", + "task" : null, + "is_active" : true, + "date_joined" : "2019-08-10T18:07:19.699Z" + }, + "pk" : 1, + "model" : "accounts.user" + }, + { + "pk" : 1, + "model" : "django_celery_beat.intervalschedule", + "fields" : { + "period" : "minutes", + "every" : 5 + } + }, + { + "pk" : 2, + "model" : "django_celery_beat.intervalschedule", + "fields" : { + "every" : 30, + "period" : "minutes" + } + }, + { + "fields" : { + "every" : 3, + "period" : "hours" + }, + "model" : "django_celery_beat.intervalschedule", + "pk" : 3 + }, + { + "fields" : { + "minute" : "0", + "timezone" : "UTC", + "day_of_month" : "*", + "hour" : "4", + "month_of_year" : "*", + "day_of_week" : "*" + }, + "model" : "django_celery_beat.crontabschedule", + "pk" : 1 + }, + { + "fields" : { + "last_update" : "2019-08-10T20:01:21.152Z" + }, + "model" : "django_celery_beat.periodictasks", + "pk" : 1 + }, + { + "model" : "django_celery_beat.periodictask", + "pk" : 1, + "fields" : { + "description" : "", + "last_run_at" : null, + "routing_key" : null, + "kwargs" : "{}", + "queue" : null, + "name" : "celery.backend_cleanup", + "clocked" : null, + "solar" : null, + "task" : "celery.backend_cleanup", + "one_off" : false, + "expires" : null, + "total_run_count" : 0, + "date_changed" : "2019-08-10T19:56:33.160Z", + "args" : "[]", + "start_time" : null, + "priority" : null, + "crontab" : 1, + "interval" : null, + "enabled" : true, + "headers" : "{}", + "exchange" : null + } + }, + { + "pk" : 3, + "model" : "django_celery_beat.periodictask", + "fields" : { + "exchange" : null, + "headers" : "{}", + "enabled" : true, + "priority" : null, + "crontab" : null, + "interval" : 3, + "start_time" : "2019-08-10T20:00:52Z", + "args" : "[1]", + "date_changed" : "2019-08-10T20:01:21.153Z", + "total_run_count" : 0, + "expires" : null, + "task" : "newsreader.news.collection.tasks.collect", + "one_off" : false, + "clocked" : null, + "solar" : null, + "name" : "Collection testing task", + "queue" : null, + "kwargs" : "{}", + "routing_key" : null, + "last_run_at" : null, + "description" : "" + } + }, + { + "fields" : { + "user" : 1, + "created" : "2019-08-10T19:57:53Z", + "name" : "Tech", + "modified" : "2019-08-10T19:58:02.048Z" + }, + "pk" : 1, + "model" : "core.category" + }, + { + "fields" : { + "modified" : "2019-08-10T19:58:20.951Z", + "user" : 1, + "created" : "2019-08-10T19:58:13Z", + "name" : "News" + }, + "pk" : 2, + "model" : "core.category" + }, + { + "pk" : 1, + "model" : "collection.collectionrule", + "fields" : { + "timezone" : "Europe/Amsterdam", + "succeeded" : true, + "error" : null, + "website_url" : null, + "favicon" : null, + "category" : 1, + "created" : "2019-08-10T18:08:10.520Z", + "user" : 1, + "name" : "Tweakers", + "url" : "http://feeds.feedburner.com/tweakers/mixed", + "last_suceeded" : "2019-08-10T18:25:32.325Z", + "modified" : "2019-08-10T19:59:54.547Z" + } + }, + { + "fields" : { + "favicon" : null, + "category" : 1, + "timezone" : "UTC", + "error" : null, + "website_url" : null, + "succeeded" : false, + "modified" : "2019-08-10T19:58:05.691Z", + "last_suceeded" : null, + "name" : "Hackers News", + "created" : "2019-08-10T19:58:05.615Z", + "user" : 1, + "url" : "https://news.ycombinator.com/rss" + }, + "pk" : 2, + "model" : "collection.collectionrule" + }, + { + "fields" : { + "timezone" : "UTC", + "error" : null, + "website_url" : null, + "succeeded" : false, + "favicon" : null, + "category" : 2, + "name" : "BBC", + "created" : "2019-08-10T19:58:23.441Z", + "user" : 1, + "url" : "http://feeds.bbci.co.uk/news/world/rss.xml", + "modified" : "2019-08-10T19:58:23.449Z", + "last_suceeded" : null + }, + "pk" : 3, + "model" : "collection.collectionrule" + }, + { + "fields" : { + "favicon" : null, + "category" : 1, + "timezone" : "UTC", + "succeeded" : false, + "error" : null, + "website_url" : null, + "last_suceeded" : null, + "modified" : "2019-08-10T19:58:31.873Z", + "user" : 1, + "created" : "2019-08-10T19:58:31.867Z", + "name" : "Ars Technica", + "url" : "http://feeds.arstechnica.com/arstechnica/index?fmt=xml" + }, + "model" : "collection.collectionrule", + "pk" : 4 + }, + { + "pk" : 5, + "model" : "collection.collectionrule", + "fields" : { + "last_suceeded" : null, + "modified" : "2019-08-10T19:58:48.535Z", + "url" : "https://www.theguardian.com/world/rss", + "created" : "2019-08-10T19:58:48.529Z", + "user" : 1, + "name" : "The Guardian", + "category" : 2, + "favicon" : null, + "succeeded" : false, + "error" : null, + "website_url" : null, + "timezone" : "UTC" + } + }, + { + "model" : "collection.collectionrule", + "pk" : 6, + "fields" : { + "error" : null, + "website_url" : null, + "succeeded" : false, + "timezone" : "UTC", + "category" : 1, + "favicon" : null, + "url" : "https://www.engadget.com/rss.xml", + "name" : "Engadget", + "created" : "2019-08-10T19:58:58.641Z", + "user" : 1, + "modified" : "2019-08-10T19:58:58.647Z", + "last_suceeded" : null + } + }, + { + "fields" : { + "last_suceeded" : null, + "modified" : "2019-08-10T19:59:29.917Z", + "user" : 1, + "created" : "2019-08-10T19:59:29.909Z", + "name" : "The Verge", + "url" : "https://www.theverge.com/rss/index.xml", + "favicon" : null, + "category" : 1, + "timezone" : "UTC", + "succeeded" : false, + "error" : null, + "website_url" : null + }, + "model" : "collection.collectionrule", + "pk" : 7 + }, + { + "pk" : 8, + "model" : "collection.collectionrule", + "fields" : { + "modified" : "2019-08-10T19:59:44.838Z", + "last_suceeded" : null, + "name" : "News", + "user" : 1, + "created" : "2019-08-10T19:59:44.833Z", + "url" : "http://feeds.boingboing.net/boingboing/iBag", + "favicon" : null, + "category" : 2, + "timezone" : "UTC", + "website_url" : null, + "error" : null, + "succeeded" : false + } + } +] diff --git a/src/newsreader/js/components/LoadingIndicator.js b/src/newsreader/js/components/LoadingIndicator.js new file mode 100644 index 0000000..b3a3cb6 --- /dev/null +++ b/src/newsreader/js/components/LoadingIndicator.js @@ -0,0 +1,13 @@ +import React from 'react'; + +const LoadingIndicator = props => { + return ( +
+
+
+
+
+ ); +}; + +export default LoadingIndicator; diff --git a/src/newsreader/js/homepage/App.js b/src/newsreader/js/homepage/App.js new file mode 100644 index 0000000..fa9bc2c --- /dev/null +++ b/src/newsreader/js/homepage/App.js @@ -0,0 +1,63 @@ +import React from 'react'; + +import { connect } from 'react-redux'; +import { isEqual } from 'lodash'; + +import { fetchCategories } from './actions/categories'; + +import Sidebar from './components/sidebar/Sidebar.js'; +import FeedList from './components/feedlist/FeedList.js'; +import PostModal from './components/PostModal.js'; + +class App extends React.Component { + componentDidMount() { + this.props.fetchCategories(); + } + + render() { + return ( + <> +
+ + + + {!isEqual(this.props.post, {}) && ( + + )} +
+ + ); + } +} + +const mapStateToProps = state => { + if (!isEqual(state.selected.post, {})) { + const ruleId = state.selected.post.rule; + + const rule = state.rules.items[ruleId]; + const category = state.categories.items[rule.category]; + + return { + post: state.selected.post, + rule, + category, + }; + } + + return { + post: state.selected.post, + }; +}; + +const mapDispatchToProps = dispatch => ({ + fetchCategories: () => dispatch(fetchCategories()), +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(App); diff --git a/src/newsreader/js/homepage/actions/categories.js b/src/newsreader/js/homepage/actions/categories.js new file mode 100644 index 0000000..07a1066 --- /dev/null +++ b/src/newsreader/js/homepage/actions/categories.js @@ -0,0 +1,86 @@ +import { requestRules, receiveRules, fetchRulesByCategory } from './rules.js'; + +export const SELECT_CATEGORY = 'SELECT_CATEGORY'; + +export const RECEIVE_CATEGORY = 'RECEIVE_CATEGORY'; +export const RECEIVE_CATEGORIES = 'RECEIVE_CATEGORIES'; + +export const REQUEST_CATEGORY = 'REQUEST_CATEGORY'; +export const REQUEST_CATEGORIES = 'REQUEST_CATEGORIES'; + +export const selectCategory = category => ({ + type: SELECT_CATEGORY, + item: category, +}); + +export const receiveCategory = category => ({ + type: RECEIVE_CATEGORY, + category, +}); + +export const receiveCategories = json => ({ + type: RECEIVE_CATEGORIES, + categories: json, +}); + +export const requestCategory = () => ({ type: REQUEST_CATEGORY }); +export const requestCategories = () => ({ type: REQUEST_CATEGORIES }); + +export const fetchCategories = () => { + return dispatch => { + dispatch(requestCategories()); + + return fetch('/api/categories/') + .then(response => response.json()) + .then(json => { + const categories = {}; + + json.forEach(category => { + categories[category.id] = { ...category }; + }); + + dispatch(receiveCategories(categories)); + return json; + }) + .then(json => { + const promises = json.map(category => { + return fetch(`/api/categories/${category.id}/rules/`); + }); + + dispatch(requestRules()); + return Promise.all(promises); + }) + .then(responses => { + return Promise.all(responses.map(response => response.json())); + }) + .then(responseData => { + let rules = {}; + + responseData.forEach(json => { + const data = Object.values(json); + + data.forEach(item => { + rules = { ...rules, [item.id]: item }; + }); + }); + + setTimeout(dispatch, 500, receiveRules(rules)); + }); + }; +}; + +export const fetchCategory = category => { + return dispatch => { + dispatch(requestCategory()); + + return fetch(`/api/categories/${category.id}`) + .then(response => response.json()) + .then(json => { + dispatch(receiveCategory({ ...json })); + + if (category.unread === 0) { + dispatch(fetchRulesByCategory(category)); + } + }); + }; +}; diff --git a/src/newsreader/js/homepage/actions/posts.js b/src/newsreader/js/homepage/actions/posts.js new file mode 100644 index 0000000..9951859 --- /dev/null +++ b/src/newsreader/js/homepage/actions/posts.js @@ -0,0 +1,119 @@ +export const SELECT_POST = 'SELECT_POST'; +export const UNSELECT_POST = 'UNSELECT_POST'; + +export const RECEIVE_POSTS = 'RECEIVE_POSTS'; +export const RECEIVE_POST = 'RECEIVE_POST'; +export const REQUEST_POSTS = 'REQUEST_POSTS'; + +export const MARK_POST_READ = 'MARK_POST_READ'; + +export const selectPost = post => ({ + type: SELECT_POST, + post, +}); + +export const unSelectPost = () => ({ + type: UNSELECT_POST, +}); + +export const postRead = (post, rule, category) => ({ + type: MARK_POST_READ, + category: category, + post: post, + rule: rule, +}); + +export const markPostRead = (post, token) => { + return (dispatch, getState) => { + const { rules } = getState(); + const { categories } = getState(); + + const url = `/api/posts/${post.id}/`; + const options = { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': token, + }, + body: JSON.stringify({ read: true }), + }; + + const rule = rules.items[post.rule]; + const category = categories.items[rule.category]; + + return fetch(url, options) + .then(response => response.json()) + .then(updatedPost => { + const updatedRule = { ...rule, unread: rule.unread - 1 }; + const updatedCategory = { ...category, unread: category.unread - 1 }; + + dispatch(receivePost({ ...updatedPost })); + dispatch(postRead({ ...updatedPost }, updatedRule, updatedCategory)); + }); + }; +}; + +export const receivePosts = json => ({ + type: RECEIVE_POSTS, + posts: json.items, + next: json.next, +}); + +export const receivePost = post => ({ + type: RECEIVE_POST, + post, +}); + +export const requestPosts = () => ({ type: REQUEST_POSTS }); + +export const fetchPostsByCategory = (category, page = false) => { + return dispatch => { + dispatch(requestPosts()); + + const url = page ? page : `/api/categories/${category.id}/posts/?read=false`; + return fetch(url) + .then(response => response.json()) + .then(json => { + const posts = {}; + + json.results.forEach(post => { + posts[post.id] = post; + }); + + dispatch(receivePosts({ items: posts, next: json.next })); + }) + .catch(error => { + if (error instanceof TypeError) { + console.log(`Unable to parse posts from request: ${error}`); + } + + dispatch(receivePosts({ items: {}, next: null })); + }); + }; +}; + +export const fetchPostsByRule = (rule, page = false) => { + return dispatch => { + dispatch(requestPosts()); + + const url = page ? page : `/api/rules/${rule.id}/posts/?read=false`; + return fetch(url) + .then(response => response.json()) + .then(json => { + const posts = {}; + + json.results.forEach(post => { + posts[post.id] = post; + }); + + dispatch(receivePosts({ items: posts, next: json.next })); + }) + .catch(error => { + if (error instanceof TypeError) { + console.log(`Unable to parse posts from request: ${error}`); + } + + dispatch(receivePosts({ items: {}, next: null })); + }); + }; +}; diff --git a/src/newsreader/js/homepage/actions/rules.js b/src/newsreader/js/homepage/actions/rules.js new file mode 100644 index 0000000..0b843f6 --- /dev/null +++ b/src/newsreader/js/homepage/actions/rules.js @@ -0,0 +1,68 @@ +import { fetchCategory } from './categories.js'; + +export const SELECT_RULE = 'SELECT_RULE'; +export const SELECT_RULES = 'SELECT_RULES'; + +export const RECEIVE_RULE = 'RECEIVE_RULE'; +export const RECEIVE_RULES = 'RECEIVE_RULES'; + +export const REQUEST_RULE = 'REQUEST_RULE'; +export const REQUEST_RULES = 'REQUEST_RULES'; + +export const selectRule = rule => ({ + type: SELECT_RULE, + item: rule, +}); + +export const requestRule = () => ({ type: REQUEST_RULE }); +export const requestRules = () => ({ type: REQUEST_RULES }); + +export const receiveRule = rule => ({ + type: RECEIVE_RULE, + rule, +}); + +export const receiveRules = rules => ({ + type: RECEIVE_RULES, + rules, +}); + +export const fetchRule = rule => { + return (dispatch, getState) => { + dispatch(requestRule()); + + const { categories } = getState(); + const category = categories['items'][rule.category]; + + return fetch(`/api/rules/${rule.id}`) + .then(response => response.json()) + .then(receivedRule => { + dispatch(receiveRule({ ...receivedRule })); + + // fetch & update category info when the rule is read + if (rule.unread === 0) { + dispatch(fetchCategory({ ...category })); + } + }); + }; +}; + +export const fetchRulesByCategory = category => { + return (dispatch, getState) => { + dispatch(requestRules()); + + return fetch(`/api/categories/${category.id}/rules/`) + .then(response => response.json()) + .then(responseData => { + dispatch(receiveRules()); + + const rules = {}; + + responseData.forEach(rule => { + rules[rule.id] = { ...rule }; + }); + + setTimeout(dispatch, 500, receiveRules(rules)); + }); + }; +}; diff --git a/src/newsreader/js/homepage/actions/selected.js b/src/newsreader/js/homepage/actions/selected.js new file mode 100644 index 0000000..95b8603 --- /dev/null +++ b/src/newsreader/js/homepage/actions/selected.js @@ -0,0 +1,65 @@ +import { receiveCategory, requestCategory } from './categories.js'; +import { receiveRule, requestRule } from './rules.js'; + +export const MARK_SECTION_READ = 'MARK_SECTION_READ'; + +export const markSectionRead = (category, rule = {}) => ({ + category: category, + rule: rule, + type: MARK_SECTION_READ, +}); + +const markCategoryRead = (category, token) => { + return dispatch => { + dispatch(requestCategory(category)); + + const url = `/api/categories/${category.id}/read/`; + const options = { + method: 'POST', + headers: { + 'X-CSRFToken': token, + }, + }; + + return fetch(url, options) + .then(response => response.json()) + .then(updatedCategory => { + dispatch(receiveCategory({ ...updatedCategory })); + dispatch(markSectionRead({ ...category, ...updatedCategory })); + }); + }; +}; + +const markRuleRead = (rule, token) => { + return (dispatch, getState) => { + const { categories } = getState(); + const category = categories.items[rule.category]; + + dispatch(requestRule(rule)); + + const url = `/api/rules/${rule.id}/read/`; + const options = { + method: 'POST', + headers: { + 'X-CSRFToken': token, + }, + }; + + return fetch(url, options) + .then(response => response.json()) + .then(updatedRule => { + dispatch(receiveRule({ ...updatedRule })); + + // Use the old rule to decrement category with old unread count + dispatch(markSectionRead({ ...category }, { ...rule })); + }); + }; +}; + +export const markRead = (selected, token) => { + if ('category' in selected) { + return markRuleRead(selected, token); + } else { + return markCategoryRead(selected, token); + } +}; diff --git a/src/newsreader/js/homepage/components/PostModal.js b/src/newsreader/js/homepage/components/PostModal.js new file mode 100644 index 0000000..4749ccc --- /dev/null +++ b/src/newsreader/js/homepage/components/PostModal.js @@ -0,0 +1,84 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import Cookies from 'js-cookie'; + +import { unSelectPost, markPostRead } from '../actions/posts.js'; +import { formatDatetime } from '../../utils.js'; + +class PostModal extends React.Component { + readTimer = null; + + componentDidMount() { + const post = { ...this.props.post }; + const markPostRead = this.props.markPostRead; + const token = Cookies.get('csrftoken'); + + if (!post.read) { + this.readTimer = setTimeout(markPostRead, 30000, post, token); + } + } + + componentWillUnmount() { + if (this.readTimer) { + clearTimeout(this.readTimer); + } + + this.readTimer = null; + } + + render() { + const post = this.props.post; + const publicationDate = formatDatetime(post.publication_date); + const titleClassName = post.read ? 'post__title post__title--read' : 'post__title'; + + return ( +
+
+ +
+

+ {`${post.title} `} + + + +

+ {publicationDate} +
+ + + {/* HTML is sanitized by the collectors */} +
+
+
+ ); + } +} + +const mapDispatchToProps = dispatch => ({ + unSelectPost: () => dispatch(unSelectPost()), + markPostRead: (post, token) => dispatch(markPostRead(post, token)), +}); + +export default connect( + null, + mapDispatchToProps +)(PostModal); diff --git a/src/newsreader/js/homepage/components/feedlist/FeedList.js b/src/newsreader/js/homepage/components/feedlist/FeedList.js new file mode 100644 index 0000000..47fa5fb --- /dev/null +++ b/src/newsreader/js/homepage/components/feedlist/FeedList.js @@ -0,0 +1,102 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { isEqual } from 'lodash'; + +import { fetchPostsByRule, fetchPostsByCategory } from '../../actions/posts.js'; +import { filterPosts } from './filters.js'; + +import LoadingIndicator from '../../../components/LoadingIndicator.js'; +import RuleItem from './RuleItem.js'; + +class FeedList extends React.Component { + checkScrollHeight = ::this.checkScrollHeight; + + componentDidMount() { + window.addEventListener('scroll', this.checkScrollHeight); + } + + componentWillUnmount() { + window.removeEventListener('scroll', this.checkScrollHeight); + } + + checkScrollHeight(e) { + const currentHeight = window.scrollY + window.innerHeight; + const totalHeight = document.body.offsetHeight; + + const currentPercentage = (currentHeight / totalHeight) * 100; + + if (this.props.next && !this.props.lastReached) { + if (currentPercentage > 60 && !this.props.isFetching) { + this.paginate(); + } + } + } + + paginate() { + if ('category' in this.props.selected) { + return this.props.fetchPostsByRule(this.props.selected, this.props.next); + } else { + return this.props.fetchPostsByCategory(this.props.selected, this.props.next); + } + } + + render() { + const ruleItems = this.props.posts.map((item, index) => { + return ; + }); + + if (ruleItems.length > 0) { + return ( +
+ {ruleItems} + {this.props.isFetching && } +
+ ); + } else if (isEqual(this.props.selected, {})) { + return ( +
+
+ +

+ Select a category or rule to show its unread posts +

+
+
+ ); + } else if (ruleItems.length === 0) { + return ( +
+
+

+ No unread posts from the selected section at this moment, try again later +

+
+
+ ); + } else { + return ( +
{this.props.isFetching && }
+ ); + } + } +} + +const mapStateToProps = state => ({ + isFetching: state.posts.isFetching, + posts: filterPosts(state), + next: state.selected.next, + lastReached: state.selected.lastReached, + selected: state.selected.item, +}); + +const mapDispatchToProps = dispatch => ({ + fetchPostsByRule: (rule, page = false) => dispatch(fetchPostsByRule(rule, page)), + fetchPostsByCategory: (category, page = false) => { + dispatch(fetchPostsByCategory(category, page)); + }, +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(FeedList); diff --git a/src/newsreader/js/homepage/components/feedlist/PostItem.js b/src/newsreader/js/homepage/components/feedlist/PostItem.js new file mode 100644 index 0000000..6511830 --- /dev/null +++ b/src/newsreader/js/homepage/components/feedlist/PostItem.js @@ -0,0 +1,50 @@ +import React from 'react'; +import { connect } from 'react-redux'; + +import { selectPost } from '../../actions/posts.js'; + +import { formatDatetime } from '../../../utils.js'; + +class PostItem extends React.Component { + render() { + const post = this.props.post; + const publicationDate = formatDatetime(post.publication_date); + const titleClassName = post.read + ? 'posts-header__title posts-header__title--read' + : 'posts-header__title'; + + return ( +
  • { + this.props.selectPost(post); + }} + > +
    +
    + {post.title} +
    + + + + +
    + {publicationDate} +
  • + ); + } +} + +const mapDispatchToProps = dispatch => ({ + selectPost: post => dispatch(selectPost(post)), +}); + +export default connect( + null, + mapDispatchToProps +)(PostItem); diff --git a/src/newsreader/js/homepage/components/feedlist/RuleItem.js b/src/newsreader/js/homepage/components/feedlist/RuleItem.js new file mode 100644 index 0000000..08cb3aa --- /dev/null +++ b/src/newsreader/js/homepage/components/feedlist/RuleItem.js @@ -0,0 +1,25 @@ +import React from 'react'; + +import PostItem from './PostItem.js'; + +class RuleItem extends React.Component { + render() { + const posts = Object.values(this.props.posts).sort((firstEl, secondEl) => { + return new Date(secondEl.publication_date) - new Date(firstEl.publication_date); + }); + + const postItems = posts.map(post => { + return ; + }); + + return ( +
    +

    {this.props.rule.name}

    + {/* TODO: Add empty posts message */} +
      {postItems}
    +
    + ); + } +} + +export default RuleItem; diff --git a/src/newsreader/js/homepage/components/feedlist/filters.js b/src/newsreader/js/homepage/components/feedlist/filters.js new file mode 100644 index 0000000..c392775 --- /dev/null +++ b/src/newsreader/js/homepage/components/feedlist/filters.js @@ -0,0 +1,44 @@ +const isEmpty = (object = {}) => { + return Object.keys(object).length === 0; +}; + +export const filterPostsByRule = (rule = {}, posts = []) => { + const filteredPosts = posts.filter(post => { + return post.rule === rule.id && !post.read; + }); + + return filteredPosts.length > 0 ? [{ rule, posts: filteredPosts }] : []; +}; + +export const filterPostsByCategory = (category = {}, rules = [], posts = []) => { + const filteredRules = rules.filter(rule => { + return rule.category === category.id; + }); + + const filteredData = filteredRules.map(rule => { + const filteredPosts = posts.filter(post => { + return post.rule === rule.id && !post.read; + }); + + return { + rule: { ...rule }, + posts: filteredPosts, + }; + }); + + return filteredData.filter(rule => rule.posts.length > 0); +}; + +export const filterPosts = state => { + const posts = Object.values({ ...state.posts.items }); + + if (!isEmpty(state.selected.item) && !('category' in state.selected.item)) { + const rules = Object.values({ ...state.rules.items }); + + return filterPostsByCategory({ ...state.selected.item }, rules, posts); + } else if ('category' in state.selected.item) { + return filterPostsByRule({ ...state.selected.item }, posts); + } + + return []; +}; diff --git a/src/newsreader/js/homepage/components/sidebar/CategoryItem.js b/src/newsreader/js/homepage/components/sidebar/CategoryItem.js new file mode 100644 index 0000000..7644fd7 --- /dev/null +++ b/src/newsreader/js/homepage/components/sidebar/CategoryItem.js @@ -0,0 +1,68 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { isEqual } from 'lodash'; + +import { selectCategory, fetchCategory } from '../../actions/categories.js'; +import { fetchPostsByCategory } from '../../actions/posts.js'; +import RuleItem from './RuleItem.js'; + +class CategoryItem extends React.Component { + state = { open: false }; + + toggleRules() { + this.setState({ open: !this.state.open }); + } + + handleSelect() { + const category = this.props.category; + + this.props.selectCategory(category); + this.props.fetchPostsByCategory(category); + + if (category.unread === 0) { + this.props.fetchCategory(category); + } + } + + render() { + const imageSrc = this.state.open + ? '/static/chevron-down.svg' + : '/static/chevron-right.svg'; + const selected = isEqual(this.props.category, this.props.selected); + const className = selected ? 'category category--selected' : 'category'; + + const ruleItems = this.props.rules.map(rule => { + return ; + }); + + return ( +
  • +
    +
    this.toggleRules()}> + +
    + +
    this.handleSelect()}> +

    {this.props.category.name}

    + {this.props.category.unread} +
    +
    + + {ruleItems.length > 0 && this.state.open && ( +
      {ruleItems}
    + )} +
  • + ); + } +} + +const mapDispatchToProps = dispatch => ({ + selectCategory: category => dispatch(selectCategory(category)), + fetchPostsByCategory: category => dispatch(fetchPostsByCategory(category)), + fetchCategory: category => dispatch(fetchCategory(category)), +}); + +export default connect( + null, + mapDispatchToProps +)(CategoryItem); diff --git a/src/newsreader/js/homepage/components/sidebar/ReadButton.js b/src/newsreader/js/homepage/components/sidebar/ReadButton.js new file mode 100644 index 0000000..45e3781 --- /dev/null +++ b/src/newsreader/js/homepage/components/sidebar/ReadButton.js @@ -0,0 +1,36 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import Cookies from 'js-cookie'; + +import { markRead } from '../../actions/selected.js'; + +class ReadButton extends React.Component { + markSelectedRead = ::this.markSelectedRead; + + markSelectedRead() { + const token = Cookies.get('csrftoken'); + + if (this.props.selected.unread > 0) { + this.props.markRead({ ...this.props.selected }, token); + } + } + + render() { + return ( + + ); + } +} + +const mapDispatchToProps = dispatch => ({ + markRead: (selected, token) => dispatch(markRead(selected, token)), +}); + +const mapStateToProps = state => ({ selected: state.selected.item }); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(ReadButton); diff --git a/src/newsreader/js/homepage/components/sidebar/RuleItem.js b/src/newsreader/js/homepage/components/sidebar/RuleItem.js new file mode 100644 index 0000000..0a55aab --- /dev/null +++ b/src/newsreader/js/homepage/components/sidebar/RuleItem.js @@ -0,0 +1,56 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { isEqual } from 'lodash'; + +import { selectRule, fetchRule } from '../../actions/rules.js'; +import { fetchPostsByRule } from '../../actions/posts.js'; + +class RuleItem extends React.Component { + handleSelect() { + const rule = { ...this.props.rule }; + + this.props.selectRule(rule); + this.props.fetchPostsByRule(rule); + + if (rule.unread === 0) { + this.props.fetchRule(rule); + } + } + + render() { + const selected = isEqual(this.props.selected, this.props.rule); + const className = `rules__item ${selected ? 'rules__item--selected' : ''}`; + + return ( +
  • this.handleSelect()}> +
    + {this.props.rule.favicon && ( + + + + )} +
    + {this.props.rule.name} +
    +
    + {this.props.rule.unread} +
  • + ); + } +} + +const mapDispatchToProps = dispatch => ({ + selectRule: rule => dispatch(selectRule(rule)), + fetchPostsByRule: rule => dispatch(fetchPostsByRule(rule)), + fetchRule: rule => dispatch(fetchRule(rule)), +}); + +export default connect( + null, + mapDispatchToProps +)(RuleItem); diff --git a/src/newsreader/js/homepage/components/sidebar/Sidebar.js b/src/newsreader/js/homepage/components/sidebar/Sidebar.js new file mode 100644 index 0000000..55595ea --- /dev/null +++ b/src/newsreader/js/homepage/components/sidebar/Sidebar.js @@ -0,0 +1,52 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { isEqual } from 'lodash'; + +import { filterCategories, filterRules } from './filters.js'; + +import LoadingIndicator from '../../../components/LoadingIndicator.js'; +import CategoryItem from './CategoryItem.js'; +import ReadButton from './ReadButton.js'; + +// TODO: show empty category message +class Sidebar extends React.Component { + render() { + const items = this.props.categories.items.map(category => { + const rules = this.props.rules.items.filter(rule => { + return rule.category === category.id; + }); + + return ( + + ); + }); + + return ( +
    + + + {!isEqual(this.props.selected.item, {}) && } +
    + ); + } +} + +const mapStateToProps = state => ({ + categories: { ...state.categories, items: filterCategories(state.categories.items) }, + rules: { ...state.rules, items: filterRules(state.rules.items) }, + selected: state.selected, +}); + +export default connect(mapStateToProps)(Sidebar); diff --git a/src/newsreader/js/homepage/components/sidebar/filters.js b/src/newsreader/js/homepage/components/sidebar/filters.js new file mode 100644 index 0000000..5e51d6c --- /dev/null +++ b/src/newsreader/js/homepage/components/sidebar/filters.js @@ -0,0 +1,7 @@ +export const filterCategories = (categories = {}) => { + return Object.values({ ...categories }); +}; + +export const filterRules = (rules = {}) => { + return Object.values({ ...rules }); +}; diff --git a/src/newsreader/js/homepage/configureStore.js b/src/newsreader/js/homepage/configureStore.js new file mode 100644 index 0000000..e00952f --- /dev/null +++ b/src/newsreader/js/homepage/configureStore.js @@ -0,0 +1,18 @@ +import { createStore, applyMiddleware } from 'redux'; +import thunkMiddleware from 'redux-thunk'; + +import { createLogger } from 'redux-logger'; + +import rootReducer from './reducers/index.js'; + +const loggerMiddleware = createLogger(); + +const configureStore = preloadedState => { + return createStore( + rootReducer, + preloadedState, + applyMiddleware(thunkMiddleware, loggerMiddleware) + ); +}; + +export default configureStore; diff --git a/src/newsreader/js/homepage/index.js b/src/newsreader/js/homepage/index.js new file mode 100644 index 0000000..c6ce2a2 --- /dev/null +++ b/src/newsreader/js/homepage/index.js @@ -0,0 +1,16 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; + +import { Provider } from 'react-redux'; +import configureStore from './configureStore.js'; + +import App from './App.js'; + +const store = configureStore(); + +ReactDOM.render( + + + , + document.getElementsByClassName('content')[0] +); diff --git a/src/newsreader/js/homepage/reducers/categories.js b/src/newsreader/js/homepage/reducers/categories.js new file mode 100644 index 0000000..f34930e --- /dev/null +++ b/src/newsreader/js/homepage/reducers/categories.js @@ -0,0 +1,116 @@ +import { isEqual } from 'lodash'; + +import { + RECEIVE_CATEGORY, + RECEIVE_CATEGORIES, + REQUEST_CATEGORY, + REQUEST_CATEGORIES, +} from '../actions/categories.js'; + +import { RECEIVE_RULE, RECEIVE_RULES } from '../actions/rules.js'; + +import { MARK_POST_READ } from '../actions/posts.js'; +import { MARK_SECTION_READ } from '../actions/selected.js'; + +const defaultState = { items: {}, isFetching: false }; + +export const categories = (state = { ...defaultState }, action) => { + switch (action.type) { + case RECEIVE_CATEGORY: + return { + ...state, + items: { + ...state.items, + [action.category.id]: { ...action.category, rules: {} }, + }, + isFetching: false, + }; + case RECEIVE_CATEGORIES: + const receivedCategories = {}; + + Object.values({ ...action.categories }).forEach(category => { + receivedCategories[category.id] = { + ...category, + rules: {}, + }; + }); + + return { + ...state, + items: { ...state.items, ...receivedCategories }, + isFetching: false, + }; + case RECEIVE_RULE: + const category = { ...state.items[action.rule.category] }; + + category['rules'][action.rule.id] = { ...action.rule }; + + return { + ...state, + items: { + ...state.items, + [category.id]: { ...category }, + }, + }; + case RECEIVE_RULES: + const relevantCategories = {}; + + Object.values({ ...action.rules }).forEach(rule => { + if (!(rule.category in relevantCategories)) { + const category = { ...state.items[rule.category] }; + + relevantCategories[rule.category] = { + ...category, + rules: { + ...category.rules, + [rule.id]: { ...rule }, + }, + }; + } else { + relevantCategories[rule.category]['rules'][rule.id] = { ...rule }; + } + }); + + return { + ...state, + items: { + ...state.items, + ...relevantCategories, + }, + }; + case REQUEST_CATEGORIES: + case REQUEST_CATEGORY: + return { + ...state, + isFetching: true, + }; + case MARK_POST_READ: + return { + ...state, + items: { ...state.items, [action.category.id]: { ...action.category } }, + }; + case MARK_SECTION_READ: + if (!isEqual(action.rule, {})) { + return { + ...state, + items: { + ...state.items, + [action.category.id]: { + ...action.category, + unread: action.category.unread - action.rule.unread, + }, + }, + }; + } + + return { + ...state, + items: { + ...state.items, + [action.category.id]: { ...action.category, unread: 0 }, + }, + }; + default: + return state; + } +}; diff --git a/src/newsreader/js/homepage/reducers/index.js b/src/newsreader/js/homepage/reducers/index.js new file mode 100644 index 0000000..f70ca2a --- /dev/null +++ b/src/newsreader/js/homepage/reducers/index.js @@ -0,0 +1,10 @@ +import { combineReducers } from 'redux'; + +import { categories } from './categories.js'; +import { rules } from './rules.js'; +import { posts } from './posts.js'; +import { selected } from './selected.js'; + +const rootReducer = combineReducers({ categories, rules, posts, selected }); + +export default rootReducer; diff --git a/src/newsreader/js/homepage/reducers/posts.js b/src/newsreader/js/homepage/reducers/posts.js new file mode 100644 index 0000000..4f613ab --- /dev/null +++ b/src/newsreader/js/homepage/reducers/posts.js @@ -0,0 +1,67 @@ +import { isEqual } from 'lodash'; + +import { + SELECT_POST, + RECEIVE_POST, + RECEIVE_POSTS, + REQUEST_POSTS, +} from '../actions/posts.js'; +import { SELECT_CATEGORY } from '../actions/categories.js'; +import { SELECT_RULE } from '../actions/rules.js'; +import { MARK_SECTION_READ } from '../actions/selected.js'; + +const defaultState = { items: {}, isFetching: false }; + +export const posts = (state = { ...defaultState }, action) => { + switch (action.type) { + case RECEIVE_POSTS: + return { + ...state, + type: RECEIVE_POSTS, + isFetching: false, + items: { ...state.items, ...action.posts }, + }; + case REQUEST_POSTS: + return { + ...state, + type: REQUEST_POSTS, + isFetching: true, + }; + case RECEIVE_POST: + const items = { ...state.items, [action.post.id]: { ...action.post } }; + + return { + ...state, + items: items, + type: RECEIVE_POST, + }; + case MARK_SECTION_READ: + const updatedPosts = {}; + let relatedPosts = []; + + if (!isEqual(action.rule, {})) { + relatedPosts = Object.values({ ...state.items }).filter(post => { + return post.rule === action.rule.id; + }); + } else { + relatedPosts = Object.values({ ...state.items }).filter(post => { + return post.rule in { ...action.category.rules }; + }); + } + + relatedPosts.forEach(post => { + updatedPosts[post.id] = { ...post, read: true }; + }); + + return { + ...state, + items: { + ...state.items, + ...updatedPosts, + }, + }; + + default: + return state; + } +}; diff --git a/src/newsreader/js/homepage/reducers/rules.js b/src/newsreader/js/homepage/reducers/rules.js new file mode 100644 index 0000000..f23c98f --- /dev/null +++ b/src/newsreader/js/homepage/reducers/rules.js @@ -0,0 +1,65 @@ +import { isEqual } from 'lodash'; + +import { + REQUEST_RULES, + REQUEST_RULE, + RECEIVE_RULES, + RECEIVE_RULE, +} from '../actions/rules.js'; +import { MARK_POST_READ } from '../actions/posts.js'; +import { MARK_SECTION_READ } from '../actions/selected.js'; + +const defaultState = { items: {}, isFetching: false }; + +export const rules = (state = { ...defaultState }, action) => { + switch (action.type) { + case REQUEST_RULE: + case REQUEST_RULES: + return { + ...state, + isFetching: true, + }; + case RECEIVE_RULES: + return { + ...state, + items: { ...state.items, ...action.rules }, + isFetching: false, + }; + case RECEIVE_RULE: + return { + ...state, + items: { ...state.items, [action.rule.id]: { ...action.rule } }, + isFetching: false, + }; + case MARK_POST_READ: + case MARK_SECTION_READ: + if (!isEqual(action.rule, {})) { + return { + ...state, + items: { + ...state.items, + [action.rule.id]: { + ...action.rule, + unread: 0, + }, + }, + }; + } + + const updatedRules = {}; + Object.values({ ...state.items }).forEach(rule => { + if (rule.category === action.category.id) { + updatedRules[rule.id] = { + ...rule, + unread: 0, + }; + } else { + updatedRules[rule.id] = { ...rule }; + } + }); + + return { ...state, items: { ...updatedRules } }; + default: + return state; + } +}; diff --git a/src/newsreader/js/homepage/reducers/selected.js b/src/newsreader/js/homepage/reducers/selected.js new file mode 100644 index 0000000..dea13db --- /dev/null +++ b/src/newsreader/js/homepage/reducers/selected.js @@ -0,0 +1,70 @@ +import { isEqual } from 'lodash'; + +import { SELECT_CATEGORY } from '../actions/categories.js'; +import { SELECT_RULE } from '../actions/rules.js'; +import { + RECEIVE_POST, + RECEIVE_POSTS, + SELECT_POST, + UNSELECT_POST, +} from '../actions/posts.js'; + +import { MARK_SECTION_READ } from '../actions/selected.js'; + +const defaultState = { item: {}, next: false, lastReached: false, post: {} }; + +export const selected = (state = { ...defaultState }, action) => { + switch (action.type) { + case SELECT_CATEGORY: + case SELECT_RULE: + return { + ...state, + item: action.item, + next: false, + lastReached: false, + }; + case RECEIVE_POSTS: + return { + ...state, + next: action.next, + lastReached: !action.next, + }; + case RECEIVE_POST: + const isCurrentPost = !isEqual(state.post, {}) && state.post.id === action.post.id; + + if (isCurrentPost) { + return { + ...state, + post: { ...action.post }, + }; + } + + return { + ...state, + }; + case SELECT_POST: + return { + ...state, + post: action.post, + }; + case UNSELECT_POST: + return { + ...state, + post: {}, + }; + case MARK_SECTION_READ: + if (!isEqual(action.rule, {})) { + return { + ...state, + item: { ...action.rule, unread: 0 }, + }; + } + + return { + ...state, + item: { ...action.category }, + }; + default: + return state; + } +}; diff --git a/src/newsreader/js/utils.js b/src/newsreader/js/utils.js new file mode 100644 index 0000000..0794a1a --- /dev/null +++ b/src/newsreader/js/utils.js @@ -0,0 +1,14 @@ +export const formatDatetime = dateString => { + const locale = navigator.language ? navigator.language : 'en-US'; + const dateOptions = { + hour: '2-digit', + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }; + + const date = new Date(dateString); + + return date.toLocaleDateString(locale, dateOptions); +}; diff --git a/src/newsreader/news/collection/admin.py b/src/newsreader/news/collection/admin.py index 9727b69..e82dea5 100644 --- a/src/newsreader/news/collection/admin.py +++ b/src/newsreader/news/collection/admin.py @@ -4,9 +4,10 @@ from newsreader.news.collection.models import CollectionRule class CollectionRuleAdmin(admin.ModelAdmin): - fields = ("url", "name", "timezone", "category", "favicon") + fields = ("url", "name", "timezone", "category", "favicon", "user") list_display = ("name", "category", "url", "last_suceeded", "succeeded") + list_filter = ("user",) def save_model(self, request, obj, form, change): if not change: diff --git a/src/newsreader/news/collection/endpoints.py b/src/newsreader/news/collection/endpoints.py new file mode 100644 index 0000000..02ea917 --- /dev/null +++ b/src/newsreader/news/collection/endpoints.py @@ -0,0 +1,69 @@ +from django.db.models.query import QuerySet + +from rest_framework import status +from rest_framework.generics import ( + GenericAPIView, + ListAPIView, + ListCreateAPIView, + RetrieveUpdateDestroyAPIView, + get_object_or_404, +) +from rest_framework.response import Response +from rest_framework.serializers import Serializer + +from newsreader.core.pagination import LargeResultSetPagination, ResultSetPagination +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.serializers import RuleSerializer +from newsreader.news.core.filters import ReadFilter +from newsreader.news.core.models import Post +from newsreader.news.core.serializers import PostSerializer + + +class ListRuleView(ListCreateAPIView): + queryset = CollectionRule.objects.all() + serializer_class = RuleSerializer + pagination_class = ResultSetPagination + + def get_queryset(self) -> QuerySet: + user = self.request.user + return self.queryset.filter(user=user).order_by("-created") + + +class DetailRuleView(RetrieveUpdateDestroyAPIView): + queryset = CollectionRule.objects.all() + serializer_class = RuleSerializer + pagination_class = ResultSetPagination + + +class NestedRuleView(ListAPIView): + queryset = CollectionRule.objects.prefetch_related("posts").all() + serializer_class = PostSerializer + pagination_class = LargeResultSetPagination + filter_backends = [ReadFilter] + + def get_queryset(self) -> QuerySet: + lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field + + # Default permission is IsOwner, therefore there shouldn't have to be + # filtered on the user. + filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]} + + rule = get_object_or_404(self.queryset, **filter_kwargs) + self.check_object_permissions(self.request, rule) + + return rule.posts.order_by("-publication_date") + + +class RuleReadView(GenericAPIView): + queryset = CollectionRule.objects.all() + serializer_class = RuleSerializer + + def post(self, request, *args, **kwargs): + rule = self.get_object() + + Post.objects.filter(rule=rule).update(read=True) + + rule.refresh_from_db() + serializer_class = self.get_serializer_class() + serializer = serializer_class(rule) + return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/src/newsreader/news/collection/serializers.py b/src/newsreader/news/collection/serializers.py index 4f7f3a5..640d16e 100644 --- a/src/newsreader/news/collection/serializers.py +++ b/src/newsreader/news/collection/serializers.py @@ -1,23 +1,15 @@ from rest_framework import serializers -from newsreader.news import core from newsreader.news.collection.models import CollectionRule -class CollectionRuleSerializer(serializers.HyperlinkedModelSerializer): - posts = serializers.SerializerMethodField() +class RuleSerializer(serializers.ModelSerializer): user = serializers.HiddenField(default=serializers.CurrentUserDefault()) + unread = serializers.SerializerMethodField() - def get_posts(self, instance): - request = self.context.get("request") - posts = instance.posts.order_by("-publication_date") - - serializer = core.serializers.PostSerializer( - posts, context={"request": request}, many=True - ) - return serializer.data + def get_unread(self, rule): + return rule.posts.filter(read=False).count() class Meta: model = CollectionRule - fields = ("id", "name", "url", "favicon", "category", "posts", "user") - extra_kwargs = {"category": {"view_name": "api:categories-detail"}} + fields = ("id", "name", "url", "favicon", "category", "user", "unread") diff --git a/src/newsreader/news/collection/tests/endpoints/rules/__init__.py b/src/newsreader/news/collection/tests/endpoints/rule/__init__.py similarity index 100% rename from src/newsreader/news/collection/tests/endpoints/rules/__init__.py rename to src/newsreader/news/collection/tests/endpoints/rule/__init__.py diff --git a/src/newsreader/news/collection/tests/endpoints/rules/detail/__init__.py b/src/newsreader/news/collection/tests/endpoints/rule/detail/__init__.py similarity index 100% rename from src/newsreader/news/collection/tests/endpoints/rules/detail/__init__.py rename to src/newsreader/news/collection/tests/endpoints/rule/detail/__init__.py diff --git a/src/newsreader/news/collection/tests/endpoints/rules/detail/tests.py b/src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py similarity index 59% rename from src/newsreader/news/collection/tests/endpoints/rules/detail/tests.py rename to src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py index 6a85345..8dc75d0 100644 --- a/src/newsreader/news/collection/tests/endpoints/rules/detail/tests.py +++ b/src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py @@ -1,26 +1,22 @@ import json -from urllib.parse import urljoin - from django.test import Client, TestCase from django.urls import reverse from newsreader.accounts.tests.factories import UserFactory from newsreader.news.collection.tests.factories import CollectionRuleFactory -from newsreader.news.core.tests.factories import CategoryFactory +from newsreader.news.core.models import Post +from newsreader.news.core.tests.factories import CategoryFactory, PostFactory class CollectionRuleDetailViewTestCase(TestCase): def setUp(self): - self.maxDiff = None - - self.user = UserFactory(is_staff=True, password="test") - self.client = Client() + self.user = UserFactory(password="test") + self.client.login(email=self.user.email, password="test") def test_simple(self): rule = CollectionRuleFactory(user=self.user) - self.client.force_login(self.user) response = self.client.get(reverse("api:rules-detail", args=[rule.pk])) data = response.json() @@ -31,10 +27,8 @@ class CollectionRuleDetailViewTestCase(TestCase): self.assertTrue("url" in data) self.assertTrue("favicon" in data) self.assertTrue("category" in data) - self.assertTrue("posts" in data) def test_not_known(self): - self.client.force_login(self.user) response = self.client.get(reverse("api:rules-detail", args=[100])) data = response.json() @@ -44,7 +38,6 @@ class CollectionRuleDetailViewTestCase(TestCase): def test_post(self): rule = CollectionRuleFactory(user=self.user) - self.client.force_login(self.user) response = self.client.post(reverse("api:rules-detail", args=[rule.pk])) data = response.json() @@ -54,7 +47,6 @@ class CollectionRuleDetailViewTestCase(TestCase): def test_patch(self): rule = CollectionRuleFactory(name="BBC", user=self.user) - self.client.force_login(self.user) response = self.client.patch( reverse("api:rules-detail", args=[rule.pk]), data=json.dumps({"name": "The guardian"}), @@ -65,18 +57,12 @@ class CollectionRuleDetailViewTestCase(TestCase): self.assertEquals(response.status_code, 200) self.assertEquals(data["name"], "The guardian") - def test_category_change_with_absolute_url(self): + def test_category_change(self): old_category = CategoryFactory(user=self.user) new_category = CategoryFactory(user=self.user) - base_url = "http://testserver" - relative_url = reverse("api:categories-detail", args=[new_category.pk]) - - absolute_url = urljoin(base_url, relative_url) - rule = CollectionRuleFactory(name="BBC", category=old_category, user=self.user) - self.client.force_login(self.user) response = self.client.patch( reverse("api:rules-detail", args=[rule.pk]), data=json.dumps({"category": absolute_url}), @@ -85,34 +71,11 @@ class CollectionRuleDetailViewTestCase(TestCase): data = response.json() self.assertEquals(response.status_code, 200) - self.assertEquals(data["category"], absolute_url) - - def test_category_change_with_relative_url(self): - old_category = CategoryFactory(user=self.user) - new_category = CategoryFactory(user=self.user) - - base_url = "http://testserver" - relative_url = reverse("api:categories-detail", args=[new_category.pk]) - - absolute_url = urljoin(base_url, relative_url) - - rule = CollectionRuleFactory(name="BBC", category=old_category, user=self.user) - - self.client.force_login(self.user) - response = self.client.patch( - reverse("api:rules-detail", args=[rule.pk]), - data=json.dumps({"category": relative_url}), - content_type="application/json", - ) - data = response.json() - - self.assertEquals(response.status_code, 200) - self.assertEquals(data["category"], absolute_url) + self.assertEquals(data["category"], new_category.pk) def test_identifier_cannot_be_changed(self): rule = CollectionRuleFactory(user=self.user) - self.client.force_login(self.user) response = self.client.patch( reverse("api:rules-detail", args=[rule.pk]), data=json.dumps({"id": 44}), @@ -127,26 +90,20 @@ class CollectionRuleDetailViewTestCase(TestCase): rule = CollectionRuleFactory(user=self.user) category = CategoryFactory(user=self.user) - self.client.force_login(self.user) response = self.client.patch( reverse("api:rules-detail", args=[rule.pk]), - data=json.dumps( - {"category": reverse("api:categories-detail", args=[category.pk])} - ), + data=json.dumps({"category": category.pk}), content_type="application/json", ) data = response.json() - url = data["category"] + data["category"] self.assertEquals(response.status_code, 200) - self.assertTrue( - url.endswith(reverse("api:categories-detail", args=[category.pk])) - ) + self.assertEquals(data["category"], category.pk) def test_put(self): rule = CollectionRuleFactory(name="BBC", user=self.user) - self.client.force_login(self.user) response = self.client.put( reverse("api:rules-detail", args=[rule.pk]), data=json.dumps({"name": "BBC", "url": "https://www.bbc.co.uk"}), @@ -160,12 +117,13 @@ class CollectionRuleDetailViewTestCase(TestCase): def test_delete(self): rule = CollectionRuleFactory(user=self.user) - self.client.force_login(self.user) response = self.client.delete(reverse("api:rules-detail", args=[rule.pk])) self.assertEquals(response.status_code, 204) def test_rule_with_unauthenticated_user(self): + self.client.logout() + rule = CollectionRuleFactory(name="BBC", user=self.user) response = self.client.patch( @@ -180,7 +138,6 @@ class CollectionRuleDetailViewTestCase(TestCase): other_user = UserFactory() rule = CollectionRuleFactory(name="BBC", user=other_user) - self.client.force_login(self.user) response = self.client.patch( reverse("api:rules-detail", args=[rule.pk]), data=json.dumps({"name": "The guardian"}), @@ -188,3 +145,95 @@ class CollectionRuleDetailViewTestCase(TestCase): ) self.assertEquals(response.status_code, 403) + + def test_read_count(self): + rule = CollectionRuleFactory(user=self.user) + + PostFactory.create_batch(size=20, read=False, rule=rule) + PostFactory.create_batch(size=20, read=True, rule=rule) + + response = self.client.get(reverse("api:rules-detail", args=[rule.pk])) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["unread"], 20) + + +class CollectionRuleReadTestCase(TestCase): + def setUp(self): + self.user = UserFactory(password="test") + self.client.login(email=self.user.email, password="test") + + def test_rule_read(self): + rule = CollectionRuleFactory(user=self.user) + + PostFactory.create_batch(size=20, read=False, rule=rule) + + response = self.client.post(reverse("api:rules-read", args=[rule.pk])) + data = response.json() + + self.assertEquals(response.status_code, 201) + self.assertEquals(data["unread"], 0) + + def test_rule_unknown(self): + response = self.client.post(reverse("api:rules-read", args=[101])) + + self.assertEquals(response.status_code, 404) + + def test_unauthenticated_user(self): + self.client.logout() + + rule = CollectionRuleFactory(user=self.user) + + PostFactory.create_batch(size=20, read=False, rule=rule) + + response = self.client.post(reverse("api:rules-read", args=[rule.pk])) + + self.assertEquals(response.status_code, 403) + + def test_unauthorized_user(self): + other_user = UserFactory() + rule = CollectionRuleFactory(user=other_user) + + PostFactory.create_batch(size=20, read=False, rule=rule) + + response = self.client.post(reverse("api:rules-read", args=[rule.pk])) + + self.assertEquals(response.status_code, 403) + self.assertEquals(Post.objects.filter(read=False).count(), 20) + + def test_get(self): + rule = CollectionRuleFactory(user=self.user) + + response = self.client.get(reverse("api:rules-read", args=[rule.pk])) + + self.assertEquals(response.status_code, 405) + + def test_patch(self): + rule = CollectionRuleFactory(name="BBC", user=self.user) + + response = self.client.patch( + reverse("api:rules-read", args=[rule.pk]), + data=json.dumps({"name": "Not possible"}), + content_type="application/json", + ) + + self.assertEquals(response.status_code, 405) + + def test_put(self): + rule = CollectionRuleFactory(name="BBC", user=self.user) + + response = self.client.put( + reverse("api:rules-read", args=[rule.pk]), + data=json.dumps({"name": "Not possible"}), + content_type="application/json", + ) + + self.assertEquals(response.status_code, 405) + + def test_delete(self): + rule = CollectionRuleFactory(user=self.user) + + response = self.client.delete(reverse("api:rules-read", args=[rule.pk])) + + self.assertEquals(response.status_code, 405) diff --git a/src/newsreader/news/collection/tests/endpoints/rules/list/__init__.py b/src/newsreader/news/collection/tests/endpoints/rule/list/__init__.py similarity index 100% rename from src/newsreader/news/collection/tests/endpoints/rules/list/__init__.py rename to src/newsreader/news/collection/tests/endpoints/rule/list/__init__.py diff --git a/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py b/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py new file mode 100644 index 0000000..4526bdd --- /dev/null +++ b/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py @@ -0,0 +1,371 @@ +import json + +from datetime import date, datetime, time + +from django.test import Client, TestCase +from django.urls import reverse + +import pytz + +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.core.tests.factories import CategoryFactory, PostFactory + + +class RuleListViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(password="test") + self.client.login(email=self.user.email, password="test") + + def test_simple(self): + CollectionRuleFactory.create_batch(size=3, user=self.user) + + response = self.client.get(reverse("api:rules-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertTrue("results" in data) + self.assertTrue("count" in data) + self.assertEquals(data["count"], 3) + + def test_ordering(self): + rules = [ + CollectionRuleFactory( + created=datetime.combine( + date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc + ), + user=self.user, + ), + CollectionRuleFactory( + created=datetime.combine( + date(2019, 7, 20), time(hour=18, minute=7, second=37), pytz.utc + ), + user=self.user, + ), + CollectionRuleFactory( + created=datetime.combine( + date(2019, 7, 20), time(hour=16, minute=7, second=37), pytz.utc + ), + user=self.user, + ), + ] + + response = self.client.get(reverse("api:rules-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertTrue("results" in data) + self.assertTrue("count" in data) + self.assertEquals(data["count"], 3) + + self.assertEquals(data["results"][0]["id"], rules[1].pk) + self.assertEquals(data["results"][1]["id"], rules[2].pk) + self.assertEquals(data["results"][2]["id"], rules[0].pk) + + def test_pagination_count(self): + CollectionRuleFactory.create_batch(size=80, user=self.user) + + response = self.client.get(reverse("api:rules-list"), {"count": 30}) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 80) + self.assertEquals(len(data["results"]), 30) + + def test_empty(self): + response = self.client.get(reverse("api:rules-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertTrue("results" in data) + self.assertTrue("count" in data) + + self.assertEquals(data["count"], 0) + self.assertEquals(len(data["results"]), 0) + + def test_post(self): + category = CategoryFactory(user=self.user) + + data = {"name": "BBC", "url": "https://www.bbc.co.uk", "category": category.pk} + + response = self.client.post( + reverse("api:rules-list"), + data=json.dumps(data), + content_type="application/json", + ) + data = response.json() + data["category"] + + self.assertEquals(response.status_code, 201) + + self.assertEquals(data["name"], "BBC") + self.assertEquals(data["url"], "https://www.bbc.co.uk") + self.assertEquals(data["category"], category.pk) + + def test_patch(self): + response = self.client.patch(reverse("api:rules-list")) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "PATCH" not allowed.') + + def test_put(self): + response = self.client.put(reverse("api:rules-list")) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "PUT" not allowed.') + + def test_delete(self): + response = self.client.delete(reverse("api:rules-list")) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "DELETE" not allowed.') + + def test_rules_with_unauthenticated_user(self): + self.client.logout() + + CollectionRuleFactory.create_batch(size=3, user=self.user) + + response = self.client.get(reverse("api:rules-list")) + + self.assertEquals(response.status_code, 403) + + def test_rules_with_unauthorized_user(self): + other_user = UserFactory() + CollectionRuleFactory.create_batch(size=3, user=other_user) + + response = self.client.get(reverse("api:rules-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + + self.assertEquals(data["count"], 0) + self.assertEquals(len(data["results"]), 0) + + +class NestedRuleListViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(password="test") + self.client.login(email=self.user.email, password="test") + + def test_simple(self): + rule = CollectionRuleFactory.create(user=self.user) + PostFactory.create_batch(size=5, rule=rule) + + response = self.client.get( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}) + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + + self.assertTrue("results" in data) + self.assertTrue("count" in data) + self.assertEquals(data["count"], 5) + + def test_pagination(self): + rule = CollectionRuleFactory.create(user=self.user) + PostFactory.create_batch(size=80, rule=rule) + + response = self.client.get( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}), {"count": 30} + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 80) + self.assertEquals(len(data["results"]), 30) + + def test_empty(self): + rule = CollectionRuleFactory.create(user=self.user) + + response = self.client.get( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}) + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 0) + self.assertEquals(len(data["results"]), 0) + + def test_not_known(self): + response = self.client.get(reverse("api:rules-nested-posts", kwargs={"pk": 0})) + + self.assertEquals(response.status_code, 404) + + def test_post(self): + rule = CollectionRuleFactory.create(user=self.user) + + response = self.client.post( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}), + data=json.dumps({}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "POST" not allowed.') + + def test_patch(self): + rule = CollectionRuleFactory.create(user=self.user) + + response = self.client.patch( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}), + data=json.dumps({}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "PATCH" not allowed.') + + def test_put(self): + rule = CollectionRuleFactory.create(user=self.user) + + response = self.client.put( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}), + data=json.dumps({}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "PUT" not allowed.') + + def test_delete(self): + rule = CollectionRuleFactory.create(user=self.user) + + response = self.client.delete( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}), + data=json.dumps({}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "DELETE" not allowed.') + + def test_rule_with_unauthenticated_user(self): + self.client.logout() + + rule = CollectionRuleFactory(user=self.user) + + response = self.client.get( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}) + ) + + self.assertEquals(response.status_code, 403) + + def test_rule_with_unauthorized_user(self): + other_user = UserFactory() + rule = CollectionRuleFactory(user=other_user) + + response = self.client.get( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}) + ) + + self.assertEquals(response.status_code, 403) + + def test_posts_ordering(self): + rule = CollectionRuleFactory( + user=self.user, category=CategoryFactory(user=self.user) + ) + + posts = [ + PostFactory( + title="I'm the first post", + rule=rule, + publication_date=datetime.combine( + date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc + ), + ), + PostFactory( + title="I'm the second post", + rule=rule, + publication_date=datetime.combine( + date(2019, 7, 20), time(hour=18, minute=7, second=37), pytz.utc + ), + ), + PostFactory( + title="I'm the third post", + rule=rule, + publication_date=datetime.combine( + date(2019, 7, 20), time(hour=16, minute=7, second=37), pytz.utc + ), + ), + ] + + response = self.client.get( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}) + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertTrue("results" in data) + self.assertTrue("count" in data) + self.assertEquals(data["count"], 3) + + self.assertEquals(data["results"][0]["id"], posts[1].pk) + self.assertEquals(data["results"][1]["id"], posts[2].pk) + self.assertEquals(data["results"][2]["id"], posts[0].pk) + + def test_only_posts_from_rule_are_returned(self): + rule = CollectionRuleFactory.create(user=self.user) + other_rule = CollectionRuleFactory.create(user=self.user) + + PostFactory.create_batch(size=5, rule=rule) + PostFactory.create_batch(size=5, rule=other_rule) + + response = self.client.get( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}) + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + + self.assertTrue("results" in data) + self.assertTrue("count" in data) + self.assertEquals(data["count"], 5) + + for post in data["results"]: + self.assertEquals(post["rule"], rule.pk) + + def test_unread_posts(self): + rule = CollectionRuleFactory.create(user=self.user) + + PostFactory.create_batch(size=10, rule=rule, read=False) + PostFactory.create_batch(size=10, rule=rule, read=True) + + response = self.client.get( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}), {"read": "false"} + ) + + data = response.json() + posts = data["results"] + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 10) + + for post in posts: + self.assertEquals(post["read"], False) + + def test_read_posts(self): + rule = CollectionRuleFactory.create(user=self.user) + + PostFactory.create_batch(size=20, rule=rule, read=False) + PostFactory.create_batch(size=10, rule=rule, read=True) + + response = self.client.get( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}), {"read": "true"} + ) + + data = response.json() + posts = data["results"] + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 10) + + for post in posts: + self.assertEquals(post["read"], True) diff --git a/src/newsreader/news/collection/tests/endpoints/rules/list/tests.py b/src/newsreader/news/collection/tests/endpoints/rules/list/tests.py deleted file mode 100644 index 04c7b73..0000000 --- a/src/newsreader/news/collection/tests/endpoints/rules/list/tests.py +++ /dev/null @@ -1,210 +0,0 @@ -import json - -from datetime import date, datetime, time - -from django.test import Client, TestCase -from django.urls import reverse - -import pytz - -from newsreader.accounts.tests.factories import UserFactory -from newsreader.news.collection.tests.factories import CollectionRuleFactory -from newsreader.news.core.tests.factories import CategoryFactory, PostFactory - - -class CollectionRuleListViewTestCase(TestCase): - def setUp(self): - self.maxDiff = None - - self.user = UserFactory(is_staff=True, password="test") - self.client = Client() - - def test_simple(self): - CollectionRuleFactory.create_batch(size=3, user=self.user) - - self.client.force_login(self.user) - response = self.client.get(reverse("api:rules-list")) - data = response.json() - - self.assertEquals(response.status_code, 200) - self.assertTrue("results" in data) - self.assertTrue("count" in data) - self.assertEquals(data["count"], 3) - - def test_ordering(self): - rules = [ - CollectionRuleFactory( - created=datetime.combine( - date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc - ), - user=self.user, - ), - CollectionRuleFactory( - created=datetime.combine( - date(2019, 7, 20), time(hour=18, minute=7, second=37), pytz.utc - ), - user=self.user, - ), - CollectionRuleFactory( - created=datetime.combine( - date(2019, 7, 20), time(hour=16, minute=7, second=37), pytz.utc - ), - user=self.user, - ), - ] - - self.client.force_login(self.user) - response = self.client.get(reverse("api:rules-list")) - data = response.json() - - self.assertEquals(response.status_code, 200) - self.assertTrue("results" in data) - self.assertTrue("count" in data) - self.assertEquals(data["count"], 3) - - self.assertEquals(data["results"][0]["id"], rules[1].pk) - self.assertEquals(data["results"][1]["id"], rules[2].pk) - self.assertEquals(data["results"][2]["id"], rules[0].pk) - - def test_pagination_count(self): - CollectionRuleFactory.create_batch(size=80, user=self.user) - - self.client.force_login(self.user) - response = self.client.get(reverse("api:rules-list"), {"count": 30}) - data = response.json() - - self.assertEquals(response.status_code, 200) - self.assertEquals(data["count"], 80) - self.assertEquals(len(data["results"]), 30) - - def test_empty(self): - self.client.force_login(self.user) - response = self.client.get(reverse("api:rules-list")) - data = response.json() - - self.assertEquals(response.status_code, 200) - self.assertTrue("results" in data) - self.assertTrue("count" in data) - - self.assertEquals(data["count"], 0) - self.assertEquals(len(data["results"]), 0) - - def test_post(self): - category = CategoryFactory(user=self.user) - - data = { - "name": "BBC", - "url": "https://www.bbc.co.uk", - "category": reverse("api:categories-detail", args=[category.pk]), - } - - self.client.force_login(self.user) - response = self.client.post( - reverse("api:rules-list"), - data=json.dumps(data), - content_type="application/json", - ) - data = response.json() - category_url = data["category"] - - self.assertEquals(response.status_code, 201) - - self.assertEquals(data["name"], "BBC") - self.assertEquals(data["url"], "https://www.bbc.co.uk") - - self.assertTrue( - category_url.endswith(reverse("api:categories-detail", args=[category.pk])) - ) - - def test_patch(self): - self.client.force_login(self.user) - response = self.client.patch(reverse("api:rules-list")) - data = response.json() - - self.assertEquals(response.status_code, 405) - self.assertEquals(data["detail"], 'Method "PATCH" not allowed.') - - def test_put(self): - self.client.force_login(self.user) - response = self.client.put(reverse("api:rules-list")) - data = response.json() - - self.assertEquals(response.status_code, 405) - self.assertEquals(data["detail"], 'Method "PUT" not allowed.') - - def test_delete(self): - self.client.force_login(self.user) - response = self.client.delete(reverse("api:rules-list")) - data = response.json() - - self.assertEquals(response.status_code, 405) - self.assertEquals(data["detail"], 'Method "DELETE" not allowed.') - - def test_rules_with_posts(self): - rules = { - rule: PostFactory.create_batch(size=5, rule=rule) - for rule in CollectionRuleFactory.create_batch(size=5, user=self.user) - } - - self.client.force_login(self.user) - response = self.client.get(reverse("api:rules-list")) - data = response.json() - - self.assertEquals(response.status_code, 200) - self.assertTrue("results" in data) - self.assertTrue("count" in data) - self.assertEquals(data["count"], 5) - - self.assertEquals(len(data["results"]), 5) - - self.assertEquals(len(data["results"][0]["posts"]), 5) - - def test_rules_with_posts_ordered(self): - rules = { - rule: PostFactory.create_batch(size=5, rule=rule) - for rule in CollectionRuleFactory.create_batch(size=2, user=self.user) - } - - self.client.force_login(self.user) - response = self.client.get(reverse("api:rules-list")) - data = response.json() - - first_post_set = data["results"][0]["posts"] - second_post_set = data["results"][1]["posts"] - - self.assertEquals(response.status_code, 200) - self.assertTrue("results" in data) - self.assertTrue("count" in data) - self.assertEquals(data["count"], 2) - - self.assertEquals(len(data["results"]), 2) - - for result_set in [first_post_set, second_post_set]: - for count, post in enumerate(result_set): - if count < 1: - continue - - self.assertTrue( - post["publication_date"] < result_set[count - 1]["publication_date"] - ) - - def test_rule_with_unauthenticated_user(self): - CollectionRuleFactory.create_batch(size=3, user=self.user) - - response = self.client.get(reverse("api:rules-list")) - response.json() - - self.assertEquals(response.status_code, 403) - - def test_rule_with_unauthorized_user(self): - other_user = UserFactory() - CollectionRuleFactory.create_batch(size=3, user=other_user) - - self.client.force_login(self.user) - response = self.client.get(reverse("api:rules-list")) - data = response.json() - - self.assertEquals(response.status_code, 200) - - self.assertEquals(data["count"], 0) - self.assertEquals(len(data["results"]), 0) diff --git a/src/newsreader/news/collection/urls.py b/src/newsreader/news/collection/urls.py index 4b59a09..606ec3a 100644 --- a/src/newsreader/news/collection/urls.py +++ b/src/newsreader/news/collection/urls.py @@ -1,12 +1,16 @@ from django.urls import path -from newsreader.news.collection.views import ( - CollectionRuleAPIListView, - CollectionRuleDetailView, +from newsreader.news.collection.endpoints import ( + DetailRuleView, + ListRuleView, + NestedRuleView, + RuleReadView, ) endpoints = [ - path("rules/", CollectionRuleDetailView.as_view(), name="rules-detail"), - path("rules/", CollectionRuleAPIListView.as_view(), name="rules-list"), + path("rules/", DetailRuleView.as_view(), name="rules-detail"), + path("rules//posts/", NestedRuleView.as_view(), name="rules-nested-posts"), + path("rules//read/", RuleReadView.as_view(), name="rules-read"), + path("rules/", ListRuleView.as_view(), name="rules-list"), ] diff --git a/src/newsreader/news/collection/views.py b/src/newsreader/news/collection/views.py index 2c08185..e69de29 100644 --- a/src/newsreader/news/collection/views.py +++ b/src/newsreader/news/collection/views.py @@ -1,21 +0,0 @@ -from rest_framework.generics import ListCreateAPIView, RetrieveUpdateDestroyAPIView - -from newsreader.core.pagination import ResultSetPagination -from newsreader.news.collection.models import CollectionRule -from newsreader.news.collection.serializers import CollectionRuleSerializer - - -class CollectionRuleAPIListView(ListCreateAPIView): - queryset = CollectionRule.objects.all() - serializer_class = CollectionRuleSerializer - pagination_class = ResultSetPagination - - def get_queryset(self): - user = self.request.user - return self.queryset.filter(user=user).order_by("-created") - - -class CollectionRuleDetailView(RetrieveUpdateDestroyAPIView): - queryset = CollectionRule.objects.all() - serializer_class = CollectionRuleSerializer - pagination_class = ResultSetPagination diff --git a/src/newsreader/news/core/endpoints.py b/src/newsreader/news/core/endpoints.py new file mode 100644 index 0000000..3f0207e --- /dev/null +++ b/src/newsreader/news/core/endpoints.py @@ -0,0 +1,118 @@ +from django.db.models import Q +from django.db.models.query import QuerySet + +from rest_framework import status +from rest_framework.generics import ( + GenericAPIView, + ListAPIView, + ListCreateAPIView, + RetrieveUpdateAPIView, + RetrieveUpdateDestroyAPIView, + get_object_or_404, +) +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from newsreader.accounts.permissions import IsPostOwner +from newsreader.core.pagination import LargeResultSetPagination, ResultSetPagination +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.serializers import RuleSerializer +from newsreader.news.core.filters import ReadFilter +from newsreader.news.core.models import Category, Post +from newsreader.news.core.serializers import CategorySerializer, PostSerializer + + +class ListPostView(ListAPIView): + queryset = Post.objects.all() + serializer_class = PostSerializer + pagination_class = LargeResultSetPagination + filter_backends = [ReadFilter] + + def get_queryset(self): + user = self.request.user + queryset = ( + self.queryset.filter(rule__user=user) + .filter(Q(rule__category=None) | Q(rule__category__user=user)) + .order_by("rule", "-publication_date", "-created") + ) + + return queryset + + +class DetailPostView(RetrieveUpdateAPIView): + queryset = Post.objects.all() + serializer_class = PostSerializer + permission_classes = (IsAuthenticated, IsPostOwner) + + +class ListCategoryView(ListCreateAPIView): + queryset = Category.objects.all() + serializer_class = CategorySerializer + + def get_queryset(self): + user = self.request.user + return self.queryset.filter(user=user).order_by("-created", "-modified") + + +class DetailCategoryView(RetrieveUpdateDestroyAPIView): + queryset = Category.objects.all() + serializer_class = CategorySerializer + + +class NestedRuleCategoryView(ListAPIView): + queryset = Category.objects.prefetch_related("rules").all() + serializer_class = RuleSerializer + + def get_queryset(self) -> QuerySet: + lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field + + # Default permission is IsOwner, therefore there shouldn't have to be + # filtered on the user. + filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]} + + category = get_object_or_404(self.queryset, **filter_kwargs) + self.check_object_permissions(self.request, category) + + return category.rules.order_by("name") + + +class NestedPostCategoryView(ListAPIView): + queryset = Category.objects.prefetch_related("rules", "rules__posts").all() + serializer_class = PostSerializer + pagination_class = LargeResultSetPagination + filter_backends = [ReadFilter] + + def get_queryset(self): + lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field + + # Default permission is IsOwner, therefore there shouldn't have to be + # filtered on the user. + filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]} + + category = get_object_or_404(self.queryset, **filter_kwargs) + self.check_object_permissions(self.request, category) + + queryset = Post.objects.filter( + rule__in=category.rules.values_list("id", flat=True) + ).order_by("rule__name", "-publication_date") + + return queryset + + +class CategoryReadView(GenericAPIView): + queryset = Category.objects.all() + serializer_class = CategorySerializer + + def post(self, request, *args, **kwargs): + category = self.get_object() + + ( + Post.objects.filter( + rule__in=category.rules.values_list("pk", flat=True) + ).update(read=True) + ) + + category.refresh_from_db() + serializer_class = self.get_serializer_class() + serializer = serializer_class(category) + return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/src/newsreader/news/core/filters.py b/src/newsreader/news/core/filters.py new file mode 100644 index 0000000..d322d83 --- /dev/null +++ b/src/newsreader/news/core/filters.py @@ -0,0 +1,32 @@ +from django.utils.encoding import force_text +from django.utils.translation import ugettext_lazy as _ + +from rest_framework import filters +from rest_framework.compat import coreapi, coreschema + + +class ReadFilter(filters.BaseFilterBackend): + query_param = "read" + + def filter_queryset(self, request, queryset, view): + key = request.query_params.get(self.query_param, None) + available_values = {"True": True, "true": True, "False": False, "false": False} + + if not key or key not in available_values.keys(): + return queryset + + value = available_values[key] + return queryset.filter(read=value) + + def get_schema_fields(self, view): + return [ + coreapi.Field( + name=self.query_param, + required=False, + location="query", + schema=coreschema.String( + title=force_text(self.query_param), + description=force_text(_("Wether posts should be read or not")), + ), + ) + ] diff --git a/src/newsreader/news/core/migrations/0003_post_read.py b/src/newsreader/news/core/migrations/0003_post_read.py new file mode 100644 index 0000000..8306051 --- /dev/null +++ b/src/newsreader/news/core/migrations/0003_post_read.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2 on 2019-09-09 19:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("core", "0002_auto_20190714_1425")] + + operations = [ + migrations.AddField( + model_name="post", name="read", field=models.BooleanField(default=False) + ) + ] diff --git a/src/newsreader/news/core/models.py b/src/newsreader/news/core/models.py index ce5fa16..498d9fd 100644 --- a/src/newsreader/news/core/models.py +++ b/src/newsreader/news/core/models.py @@ -12,6 +12,8 @@ class Post(TimeStampedModel): publication_date = models.DateTimeField(blank=True, null=True) url = models.URLField(max_length=1024, blank=True, null=True) + read = models.BooleanField(default=False) + rule = models.ForeignKey( CollectionRule, on_delete=models.CASCADE, editable=False, related_name="posts" ) @@ -24,7 +26,7 @@ class Post(TimeStampedModel): class Category(TimeStampedModel): - name = models.CharField(max_length=50, unique=True) + name = models.CharField(max_length=50, unique=True) # TODO remove unique value user = models.ForeignKey("accounts.User", _("Owner"), related_name="categories") class Meta: diff --git a/src/newsreader/news/core/serializers.py b/src/newsreader/news/core/serializers.py index cb6eb12..791d873 100644 --- a/src/newsreader/news/core/serializers.py +++ b/src/newsreader/news/core/serializers.py @@ -4,7 +4,7 @@ from newsreader.news import collection from newsreader.news.core.models import Category, Post -class PostSerializer(serializers.HyperlinkedModelSerializer): +class PostSerializer(serializers.ModelSerializer): class Meta: model = Post fields = ( @@ -16,24 +16,19 @@ class PostSerializer(serializers.HyperlinkedModelSerializer): "publication_date", "url", "rule", + "read", ) - extra_kwargs = {"rule": {"view_name": "api:rules-detail"}} -class CategorySerializer(serializers.HyperlinkedModelSerializer): - rules = serializers.SerializerMethodField() +class CategorySerializer(serializers.ModelSerializer): user = serializers.HiddenField(default=serializers.CurrentUserDefault()) + unread = serializers.SerializerMethodField() - def get_rules(self, instance): - request = self.context.get("request") - rules = instance.rules.order_by("-modified", "-created") - - serializer = collection.serializers.CollectionRuleSerializer( - rules, context={"request": request}, many=True - ) - return serializer.data + def get_unread(self, category): + return Post.objects.filter( + rule__in=category.rules.values_list("pk", flat=True), read=False + ).count() class Meta: model = Category - fields = ("id", "name", "rules", "user") - extra_kwargs = {"rules": {"view_name": "api:rules-detail"}} + fields = ("id", "name", "user", "unread") diff --git a/src/newsreader/news/core/templates/core/main.html b/src/newsreader/news/core/templates/core/main.html new file mode 100644 index 0000000..9de45f4 --- /dev/null +++ b/src/newsreader/news/core/templates/core/main.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% load static %} + +{% block head %} + +{% endblock %} + +{% block content %} +
    +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/src/newsreader/news/core/tests/endpoints/category/detail/tests.py b/src/newsreader/news/core/tests/endpoints/category/detail/tests.py index 251023d..787d8a9 100644 --- a/src/newsreader/news/core/tests/endpoints/category/detail/tests.py +++ b/src/newsreader/news/core/tests/endpoints/category/detail/tests.py @@ -10,25 +10,20 @@ from newsreader.news.core.tests.factories import CategoryFactory, PostFactory class CategoryDetailViewTestCase(TestCase): def setUp(self): - self.maxDiff = None - - self.client = Client() - self.user = UserFactory(is_staff=True, password="test") + self.user = UserFactory(password="test") + self.client.login(email=self.user.email, password="test") def test_simple(self): category = CategoryFactory(user=self.user) - self.client.force_login(self.user) response = self.client.get(reverse("api:categories-detail", args=[category.pk])) data = response.json() self.assertEquals(response.status_code, 200) self.assertTrue("id" in data) self.assertTrue("name" in data) - self.assertTrue("rules" in data) def test_not_known(self): - self.client.force_login(self.user) response = self.client.get(reverse("api:categories-detail", args=[100])) data = response.json() @@ -38,7 +33,6 @@ class CategoryDetailViewTestCase(TestCase): def test_post(self): category = CategoryFactory(user=self.user) - self.client.force_login(self.user) response = self.client.post(reverse("api:categories-detail", args=[category.pk])) data = response.json() @@ -48,7 +42,6 @@ class CategoryDetailViewTestCase(TestCase): def test_patch(self): category = CategoryFactory(name="Clickbait", user=self.user) - self.client.force_login(self.user) response = self.client.patch( reverse("api:categories-detail", args=[category.pk]), data=json.dumps({"name": "Interesting posts"}), @@ -62,7 +55,6 @@ class CategoryDetailViewTestCase(TestCase): def test_identifier_cannot_be_changed(self): category = CategoryFactory(user=self.user) - self.client.force_login(self.user) response = self.client.patch( reverse("api:categories-detail", args=[category.pk]), data=json.dumps({"id": 44}), @@ -76,7 +68,6 @@ class CategoryDetailViewTestCase(TestCase): def test_put(self): category = CategoryFactory(name="Clickbait", user=self.user) - self.client.force_login(self.user) response = self.client.put( reverse("api:categories-detail", args=[category.pk]), data=json.dumps({"name": "Interesting posts"}), @@ -90,74 +81,15 @@ class CategoryDetailViewTestCase(TestCase): def test_delete(self): category = CategoryFactory(user=self.user) - self.client.force_login(self.user) response = self.client.delete( reverse("api:categories-detail", args=[category.pk]) ) self.assertEquals(response.status_code, 204) - def test_rules(self): - category = CategoryFactory(user=self.user) - rules = CollectionRuleFactory.create_batch( - size=5, category=category, user=self.user - ) - - self.client.force_login(self.user) - response = self.client.get(reverse("api:categories-detail", args=[category.pk])) - data = response.json() - - self.assertEquals(response.status_code, 200) - - self.assertTrue("id" in data["rules"][0]) - self.assertTrue("name" in data["rules"][0]) - self.assertTrue("url" in data["rules"][0]) - - def test_rules_with_posts(self): - category = CategoryFactory(user=self.user) - - rules = { - rule.pk: PostFactory.create_batch(size=5, rule=rule) - for rule in CollectionRuleFactory.create_batch( - size=5, category=category, user=self.user - ) - } - - self.client.force_login(self.user) - response = self.client.get(reverse("api:categories-detail", args=[category.pk])) - data = response.json() - - self.assertEquals(response.status_code, 200) - - self.assertEquals(len(data["rules"][0]["posts"]), 5) - - def test_rules_with_posts_ordered(self): - category = CategoryFactory(user=self.user) - - rules = { - rule.pk: PostFactory.create_batch(size=5, rule=rule) - for rule in CollectionRuleFactory.create_batch( - size=5, category=category, user=self.user - ) - } - - self.client.force_login(self.user) - response = self.client.get(reverse("api:categories-detail", args=[category.pk])) - data = response.json() - - self.assertEquals(response.status_code, 200) - - posts = data["rules"][0]["posts"] - - for count, post in enumerate(posts): - if count < 1: - continue - - self.assertTrue( - post["publication_date"] < posts[count - 1]["publication_date"] - ) - def test_category_with_unauthenticated_user(self): + self.client.logout() + category = CategoryFactory(user=self.user) response = self.client.get(reverse("api:categories-detail", args=[category.pk])) @@ -168,7 +100,112 @@ class CategoryDetailViewTestCase(TestCase): other_user = UserFactory() category = CategoryFactory(user=other_user) - self.client.force_login(self.user) response = self.client.get(reverse("api:categories-detail", args=[category.pk])) self.assertEquals(response.status_code, 403) + + def test_read_count(self): + category = CategoryFactory(user=self.user) + unread_rule = CollectionRuleFactory(category=category) + read_rule = CollectionRuleFactory(category=category) + + PostFactory.create_batch(size=20, read=False, rule=unread_rule) + PostFactory.create_batch(size=20, read=True, rule=read_rule) + + response = self.client.get(reverse("api:categories-detail", args=[category.pk])) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["unread"], 20) + + +class CategoryReadTestCase(TestCase): + def setUp(self): + self.user = UserFactory(password="test") + self.client.login(email=self.user.email, password="test") + + def test_category_read(self): + category = CategoryFactory(user=self.user) + rules = [ + PostFactory.create_batch(size=5, read=False, rule=rule) + for rule in CollectionRuleFactory.create_batch(size=5, category=category) + ] + + response = self.client.post(reverse("api:categories-read", args=[category.pk])) + + data = response.json() + + self.assertEquals(response.status_code, 201) + self.assertEquals(data["unread"], 0) + self.assertEquals(data["id"], category.pk) + + def test_category_unknown(self): + response = self.client.post(reverse("api:categories-read", args=[101])) + + self.assertEquals(response.status_code, 404) + + def test_unauthenticated_user(self): + self.client.logout() + + category = CategoryFactory(user=self.user) + rules = [ + PostFactory.create_batch(size=5, read=False, rule=rule) + for rule in CollectionRuleFactory.create_batch( + size=5, category=category, user=self.user + ) + ] + + response = self.client.post(reverse("api:categories-read", args=[category.pk])) + + self.assertEquals(response.status_code, 403) + + def test_unauthorized_user(self): + other_user = UserFactory() + category = CategoryFactory(user=other_user) + + rules = [ + PostFactory.create_batch(size=5, read=False, rule=rule) + for rule in CollectionRuleFactory.create_batch( + size=5, category=category, user=other_user + ) + ] + + response = self.client.post(reverse("api:categories-read", args=[category.pk])) + + self.assertEquals(response.status_code, 403) + + def test_get(self): + category = CategoryFactory(name="Clickbait", user=self.user) + + response = self.client.get(reverse("api:categories-read", args=[category.pk])) + + self.assertEquals(response.status_code, 405) + + def test_patch(self): + category = CategoryFactory(name="Clickbait", user=self.user) + + response = self.client.patch( + reverse("api:categories-read", args=[category.pk]), + data=json.dumps({"name": "Not possible"}), + content_type="application/json", + ) + + self.assertEquals(response.status_code, 405) + + def test_put(self): + category = CategoryFactory(name="Clickbait", user=self.user) + + response = self.client.put( + reverse("api:categories-read", args=[category.pk]), + data=json.dumps({"name": "Not possible"}), + content_type="application/json", + ) + + self.assertEquals(response.status_code, 405) + + def test_delete(self): + category = CategoryFactory(name="Clickbait", user=self.user) + + response = self.client.delete(reverse("api:categories-read", args=[category.pk])) + + self.assertEquals(response.status_code, 405) diff --git a/src/newsreader/news/core/tests/endpoints/category/list/tests.py b/src/newsreader/news/core/tests/endpoints/category/list/tests.py index 974645d..f97884b 100644 --- a/src/newsreader/news/core/tests/endpoints/category/list/tests.py +++ b/src/newsreader/news/core/tests/endpoints/category/list/tests.py @@ -14,22 +14,17 @@ from newsreader.news.core.tests.factories import CategoryFactory, PostFactory class CategoryListViewTestCase(TestCase): def setUp(self): - self.maxDiff = None - - self.client = Client() - self.user = UserFactory(is_staff=True, password="test") + self.user = UserFactory(password="test") + self.client.login(email=self.user.email, password="test") def test_simple(self): CategoryFactory.create_batch(size=3, user=self.user) - self.client.force_login(self.user) response = self.client.get(reverse("api:categories-list")) data = response.json() self.assertEquals(response.status_code, 200) - self.assertTrue("results" in data) - self.assertTrue("count" in data) - self.assertEquals(data["count"], 3) + self.assertEquals(len(data), 3) def test_ordering(self): categories = [ @@ -53,35 +48,25 @@ class CategoryListViewTestCase(TestCase): ), ] - self.client.force_login(self.user) response = self.client.get(reverse("api:categories-list")) data = response.json() self.assertEquals(response.status_code, 200) - self.assertTrue("results" in data) - self.assertTrue("count" in data) - self.assertEquals(data["count"], 3) - self.assertEquals(data["results"][0]["id"], categories[1].pk) - self.assertEquals(data["results"][1]["id"], categories[2].pk) - self.assertEquals(data["results"][2]["id"], categories[0].pk) + self.assertEquals(data[0]["id"], categories[1].pk) + self.assertEquals(data[1]["id"], categories[2].pk) + self.assertEquals(data[2]["id"], categories[0].pk) def test_empty(self): - self.client.force_login(self.user) response = self.client.get(reverse("api:categories-list")) data = response.json() self.assertEquals(response.status_code, 200) - self.assertTrue("results" in data) - self.assertTrue("count" in data) - - self.assertEquals(data["count"], 0) - self.assertEquals(len(data["results"]), 0) + self.assertEquals(len(data), 0) def test_post(self): data = {"name": "Tech"} - self.client.force_login(self.user) response = self.client.post( reverse("api:categories-list"), data=json.dumps(data), @@ -93,7 +78,6 @@ class CategoryListViewTestCase(TestCase): self.assertEquals(response_data["name"], "Tech") def test_patch(self): - self.client.force_login(self.user) response = self.client.patch(reverse("api:categories-list")) data = response.json() @@ -101,7 +85,6 @@ class CategoryListViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "PATCH" not allowed.') def test_put(self): - self.client.force_login(self.user) response = self.client.put(reverse("api:categories-list")) data = response.json() @@ -109,66 +92,15 @@ class CategoryListViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "PUT" not allowed.') def test_delete(self): - self.client.force_login(self.user) response = self.client.delete(reverse("api:categories-list")) data = response.json() self.assertEquals(response.status_code, 405) self.assertEquals(data["detail"], 'Method "DELETE" not allowed.') - def test_rules(self): - categories = { - category.pk: CollectionRuleFactory.create_batch( - size=5, category=category, user=self.user - ) - for category in CategoryFactory.create_batch(size=5, user=self.user) - } - - self.client.force_login(self.user) - response = self.client.get(reverse("api:categories-list")) - data = response.json() - - self.assertEquals(response.status_code, 200) - self.assertTrue("results" in data) - self.assertTrue("count" in data) - self.assertEquals(data["count"], 5) - - self.assertEquals(len(data["results"]), 5) - - self.assertEquals(len(data["results"][0]["rules"]), 5) - - self.assertTrue("id" in data["results"][0]["rules"][0]) - self.assertTrue("name" in data["results"][0]["rules"][0]) - self.assertTrue("url" in data["results"][0]["rules"][0]) - self.assertTrue("posts" in data["results"][0]["rules"][0]) - - def test_rules_with_posts(self): - categories = { - category.pk: CollectionRuleFactory.create_batch( - size=5, category=category, user=self.user - ) - for category in CategoryFactory.create_batch(size=5, user=self.user) - } - - for category in categories: - for rule in categories[category]: - PostFactory.create_batch(size=5, rule=rule) - - self.client.force_login(self.user) - response = self.client.get(reverse("api:categories-list")) - data = response.json() - - self.assertEquals(response.status_code, 200) - self.assertTrue("results" in data) - self.assertTrue("count" in data) - self.assertEquals(data["count"], 5) - - self.assertEquals(len(data["results"]), 5) - - self.assertEquals(len(data["results"][0]["rules"]), 5) - self.assertEquals(len(data["results"][0]["rules"][0]["posts"]), 5) - def test_categories_with_unauthenticated_user(self): + self.client.logout() + CategoryFactory.create_batch(size=3, user=self.user) response = self.client.get(reverse("api:categories-list")) @@ -179,10 +111,458 @@ class CategoryListViewTestCase(TestCase): other_user = UserFactory() CategoryFactory.create_batch(size=3, user=other_user) - self.client.force_login(self.user) response = self.client.get(reverse("api:categories-list")) data = response.json() self.assertEquals(response.status_code, 200) - self.assertEquals(len(data["results"]), 0) + self.assertEquals(len(data), 0) + + +class NestedCategoryListViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(password="test") + self.client.login(email=self.user.email, password="test") + + def test_simple(self): + category = CategoryFactory.create(user=self.user) + rules = CollectionRuleFactory.create_batch(size=5, category=category) + + response = self.client.get( + reverse("api:categories-nested-rules", kwargs={"pk": category.pk}) + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(len(data), 5) + + self.assertTrue("id" in data[0]) + self.assertTrue("name" in data[0]) + self.assertTrue("category" in data[0]) + self.assertTrue("url" in data[0]) + self.assertTrue("favicon" in data[0]) + + def test_empty(self): + category = CategoryFactory.create(user=self.user) + + response = self.client.get( + reverse("api:categories-nested-rules", kwargs={"pk": category.pk}) + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(len(data), 0) + self.assertEquals(data, []) + + def test_not_known(self): + response = self.client.get( + reverse("api:categories-nested-rules", kwargs={"pk": 100}) + ) + + self.assertEquals(response.status_code, 404) + + def test_post(self): + response = self.client.post( + reverse("api:categories-nested-rules", kwargs={"pk": 100}), + data=json.dumps({}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "POST" not allowed.') + + def test_patch(self): + category = CategoryFactory.create(user=self.user) + + response = self.client.patch( + reverse("api:categories-nested-rules", kwargs={"pk": category.pk}), + data=json.dumps({"name": "test"}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "PATCH" not allowed.') + + def test_put(self): + category = CategoryFactory.create(user=self.user) + + response = self.client.put( + reverse("api:categories-nested-rules", kwargs={"pk": category.pk}), + data=json.dumps({"name": "test"}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "PUT" not allowed.') + + def test_delete(self): + category = CategoryFactory.create(user=self.user) + + response = self.client.delete( + reverse("api:categories-nested-rules", kwargs={"pk": category.pk}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "DELETE" not allowed.') + + def test_with_unauthenticated_user(self): + self.client.logout() + + category = CategoryFactory.create(user=self.user) + rules = CollectionRuleFactory.create_batch(size=5, category=category) + + response = self.client.get( + reverse("api:categories-nested-rules", kwargs={"pk": category.pk}) + ) + + self.assertEquals(response.status_code, 403) + + def test_with_unauthorized_user(self): + other_user = UserFactory.create() + + category = CategoryFactory.create(user=other_user) + rules = CollectionRuleFactory.create_batch(size=5, category=category) + + response = self.client.get( + reverse("api:categories-nested-rules", kwargs={"pk": category.pk}) + ) + + self.assertEquals(response.status_code, 403) + + def test_ordering(self): + category = CategoryFactory.create(user=self.user) + rules = [ + CollectionRuleFactory.create(category=category, name="Durp"), + CollectionRuleFactory.create(category=category, name="Slurp"), + CollectionRuleFactory.create(category=category, name="Burp"), + ] + + response = self.client.get( + reverse("api:categories-nested-rules", kwargs={"pk": category.pk}) + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(len(data), 3) + + self.assertEquals(data[0]["id"], rules[2].pk) + self.assertEquals(data[1]["id"], rules[0].pk) + self.assertEquals(data[2]["id"], rules[1].pk) + + def test_only_rules_from_category_are_returned(self): + other_category = CategoryFactory(user=self.user) + CollectionRuleFactory.create_batch(size=5, category=other_category) + + category = CategoryFactory.create(user=self.user) + rules = [ + CollectionRuleFactory.create(category=category, name="Durp"), + CollectionRuleFactory.create(category=category, name="Slurp"), + CollectionRuleFactory.create(category=category, name="Burp"), + ] + + response = self.client.get( + reverse("api:categories-nested-rules", kwargs={"pk": category.pk}) + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(len(data), 3) + + self.assertEquals(data[0]["id"], rules[2].pk) + self.assertEquals(data[1]["id"], rules[0].pk) + self.assertEquals(data[2]["id"], rules[1].pk) + + +class NestedCategoryPostView(TestCase): + def setUp(self): + self.user = UserFactory(password="test") + self.client.login(email=self.user.email, password="test") + + def test_simple(self): + category = CategoryFactory.create(user=self.user) + rules = { + rule.pk: PostFactory.create_batch(size=5, rule=rule) + for rule in CollectionRuleFactory.create_batch( + size=5, category=category, user=self.user + ) + } + + response = self.client.get( + reverse("api:categories-nested-posts", kwargs={"pk": category.pk}) + ) + data = response.json() + posts = data["results"] + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 25) + + self.assertTrue("id" in posts[0]) + self.assertTrue("title" in posts[0]) + self.assertTrue("body" in posts[0]) + self.assertTrue("rule" in posts[0]) + self.assertTrue("url" in posts[0]) + + def test_no_rules(self): + category = CategoryFactory.create(user=self.user) + + response = self.client.get( + reverse("api:categories-nested-posts", kwargs={"pk": category.pk}) + ) + data = response.json() + posts = data["results"] + + self.assertEquals(response.status_code, 200) self.assertEquals(data["count"], 0) + self.assertEquals(posts, []) + + def test_no_posts(self): + category = CategoryFactory.create(user=self.user) + rules = CollectionRuleFactory.create_batch( + size=5, user=self.user, category=category + ) + + response = self.client.get( + reverse("api:categories-nested-posts", kwargs={"pk": category.pk}) + ) + data = response.json() + posts = data["results"] + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 0) + self.assertEquals(posts, []) + + def test_not_known(self): + response = self.client.get( + reverse("api:categories-nested-posts", kwargs={"pk": 100}) + ) + + self.assertEquals(response.status_code, 404) + + def test_post(self): + response = self.client.post( + reverse("api:categories-nested-posts", kwargs={"pk": 100}), + data=json.dumps({}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "POST" not allowed.') + + def test_patch(self): + category = CategoryFactory.create(user=self.user) + + response = self.client.patch( + reverse("api:categories-nested-posts", kwargs={"pk": category.pk}), + data=json.dumps({}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "PATCH" not allowed.') + + def test_put(self): + category = CategoryFactory.create(user=self.user) + + response = self.client.put( + reverse("api:categories-nested-posts", kwargs={"pk": category.pk}), + data=json.dumps({}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "PUT" not allowed.') + + def test_delete(self): + category = CategoryFactory.create(user=self.user) + + response = self.client.delete( + reverse("api:categories-nested-posts", kwargs={"pk": category.pk}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "DELETE" not allowed.') + + def test_with_unauthenticated_user(self): + self.client.logout() + + category = CategoryFactory.create(user=self.user) + + response = self.client.get( + reverse("api:categories-nested-posts", kwargs={"pk": category.pk}) + ) + + self.assertEquals(response.status_code, 403) + + def test_with_unauthorized_user(self): + other_user = UserFactory.create() + category = CategoryFactory.create(user=other_user) + + response = self.client.get( + reverse("api:categories-nested-posts", kwargs={"pk": category.pk}) + ) + + self.assertEquals(response.status_code, 403) + + def test_ordering(self): + category = CategoryFactory.create(user=self.user) + + bbc_rule = CollectionRuleFactory.create( + name="BBC", category=category, user=self.user + ) + guardian_rule = CollectionRuleFactory.create( + name="The Guardian", category=category, user=self.user + ) + reuters_rule = CollectionRuleFactory.create( + name="Reuters", category=category, user=self.user + ) + + reuters_rule = [ + PostFactory.create( + title="Second Reuters post", + rule=reuters_rule, + publication_date=datetime.combine( + date(2019, 5, 21), time(hour=16, minute=7, second=37), pytz.utc + ), + ), + PostFactory.create( + title="First Reuters post", + rule=reuters_rule, + publication_date=datetime.combine( + date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc + ), + ), + ] + + guardian_posts = [ + PostFactory.create( + title="Second Guardian post", + rule=guardian_rule, + publication_date=datetime.combine( + date(2019, 5, 21), time(hour=16, minute=7, second=37), pytz.utc + ), + ), + PostFactory.create( + title="First Guardian post", + rule=guardian_rule, + publication_date=datetime.combine( + date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc + ), + ), + ] + + bbc_posts = [ + PostFactory.create( + title="Second BBC post", + rule=bbc_rule, + publication_date=datetime.combine( + date(2019, 5, 21), time(hour=16, minute=7, second=37), pytz.utc + ), + ), + PostFactory.create( + title="First BBC post", + rule=bbc_rule, + publication_date=datetime.combine( + date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc + ), + ), + ] + + response = self.client.get( + reverse("api:categories-nested-posts", kwargs={"pk": category.pk}) + ) + data = response.json() + posts = data["results"] + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 6) + + self.assertEquals(posts[0]["title"], "Second BBC post") + self.assertEquals(posts[1]["title"], "First BBC post") + + self.assertEquals(posts[2]["title"], "Second Reuters post") + self.assertEquals(posts[3]["title"], "First Reuters post") + + self.assertEquals(posts[4]["title"], "Second Guardian post") + self.assertEquals(posts[5]["title"], "First Guardian post") + + def test_only_posts_from_category_are_returned(self): + category = CategoryFactory.create(user=self.user) + other_category = CategoryFactory.create(user=self.user) + + guardian_rule = CollectionRuleFactory.create( + name="BBC", category=category, user=self.user + ) + other_rule = CollectionRuleFactory.create(name="The Guardian", user=self.user) + + guardian_posts = [ + PostFactory.create(rule=guardian_rule), + PostFactory.create(rule=guardian_rule), + ] + + other_posts = [ + PostFactory.create(rule=other_rule), + PostFactory.create(rule=other_rule), + ] + + response = self.client.get( + reverse("api:categories-nested-posts", kwargs={"pk": category.pk}) + ) + data = response.json() + posts = data["results"] + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 2) + + self.assertEquals(posts[0]["rule"], guardian_rule.pk) + self.assertEquals(posts[1]["rule"], guardian_rule.pk) + + def test_unread_posts(self): + category = CategoryFactory.create(user=self.user) + rule = CollectionRuleFactory(category=category) + + PostFactory.create_batch(size=10, rule=rule, read=False) + PostFactory.create_batch(size=10, rule=rule, read=True) + + response = self.client.get( + reverse("api:categories-nested-posts", kwargs={"pk": category.pk}), + {"read": "false"}, + ) + + data = response.json() + posts = data["results"] + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 10) + + for post in posts: + self.assertEquals(post["read"], False) + + def test_read_posts(self): + category = CategoryFactory.create(user=self.user) + rule = CollectionRuleFactory(category=category) + + PostFactory.create_batch(size=20, rule=rule, read=False) + PostFactory.create_batch(size=10, rule=rule, read=True) + + response = self.client.get( + reverse("api:categories-nested-posts", kwargs={"pk": category.pk}), + {"read": "true"}, + ) + + data = response.json() + posts = data["results"] + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 10) + + for post in posts: + self.assertEquals(post["read"], True) diff --git a/src/newsreader/news/core/tests/endpoints/post/detail/tests.py b/src/newsreader/news/core/tests/endpoints/post/detail/tests.py index 465e2f2..acc4bd1 100644 --- a/src/newsreader/news/core/tests/endpoints/post/detail/tests.py +++ b/src/newsreader/news/core/tests/endpoints/post/detail/tests.py @@ -10,11 +10,8 @@ from newsreader.news.core.tests.factories import CategoryFactory, PostFactory class PostDetailViewTestCase(TestCase): def setUp(self): - self.maxDiff = None - self.client = Client() - - self.client = Client() - self.user = UserFactory(is_staff=True, password="test") + self.user = UserFactory(password="test") + self.client.login(email=self.user.email, password="test") def test_simple(self): rule = CollectionRuleFactory( @@ -22,7 +19,6 @@ class PostDetailViewTestCase(TestCase): ) post = PostFactory(rule=rule) - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-detail", args=[post.pk])) data = response.json() @@ -38,7 +34,6 @@ class PostDetailViewTestCase(TestCase): self.assertTrue("remote_identifier" in data) def test_not_known(self): - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-detail", args=[100])) data = response.json() @@ -51,7 +46,6 @@ class PostDetailViewTestCase(TestCase): ) post = PostFactory(rule=rule) - self.client.force_login(self.user) response = self.client.post(reverse("api:posts-detail", args=[post.pk])) data = response.json() @@ -64,7 +58,6 @@ class PostDetailViewTestCase(TestCase): ) post = PostFactory(title="This is clickbait for sure", rule=rule) - self.client.force_login(self.user) response = self.client.patch( reverse("api:posts-detail", args=[post.pk]), data=json.dumps({"title": "This title is very accurate"}), @@ -81,7 +74,6 @@ class PostDetailViewTestCase(TestCase): ) post = PostFactory(title="This is clickbait for sure", rule=rule) - self.client.force_login(self.user) response = self.client.patch( reverse("api:posts-detail", args=[post.pk]), data=json.dumps({"id": 44}), @@ -101,18 +93,16 @@ class PostDetailViewTestCase(TestCase): ) post = PostFactory(title="This is clickbait for sure", rule=rule) - self.client.force_login(self.user) response = self.client.patch( reverse("api:posts-detail", args=[post.pk]), data=json.dumps({"rule": reverse("api:rules-detail", args=[new_rule.pk])}), content_type="application/json", ) data = response.json() - rule_url = data["rule"] self.assertEquals(response.status_code, 200) - self.assertTrue(rule_url.endswith(reverse("api:rules-detail", args=[rule.pk]))) + self.assertTrue(data["rule"], rule.pk) def test_put(self): rule = CollectionRuleFactory( @@ -120,7 +110,6 @@ class PostDetailViewTestCase(TestCase): ) post = PostFactory(title="This is clickbait for sure", rule=rule) - self.client.force_login(self.user) response = self.client.put( reverse("api:posts-detail", args=[post.pk]), data=json.dumps({"title": "This title is very accurate"}), @@ -137,7 +126,6 @@ class PostDetailViewTestCase(TestCase): ) post = PostFactory(rule=rule) - self.client.force_login(self.user) response = self.client.delete(reverse("api:posts-detail", args=[post.pk])) data = response.json() @@ -145,6 +133,8 @@ class PostDetailViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "DELETE" not allowed.') def test_post_with_unauthenticated_user_without_category(self): + self.client.logout() + rule = CollectionRuleFactory(user=self.user, category=None) post = PostFactory(rule=rule) @@ -153,6 +143,8 @@ class PostDetailViewTestCase(TestCase): self.assertEquals(response.status_code, 403) def test_post_with_unauthenticated_user_with_category(self): + self.client.logout() + rule = CollectionRuleFactory( user=self.user, category=CategoryFactory(user=self.user) ) @@ -167,7 +159,6 @@ class PostDetailViewTestCase(TestCase): rule = CollectionRuleFactory(user=other_user, category=None) post = PostFactory(rule=rule) - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-detail", args=[post.pk])) self.assertEquals(response.status_code, 403) @@ -179,7 +170,6 @@ class PostDetailViewTestCase(TestCase): ) post = PostFactory(rule=rule) - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-detail", args=[post.pk])) self.assertEquals(response.status_code, 403) @@ -191,7 +181,38 @@ class PostDetailViewTestCase(TestCase): ) post = PostFactory(rule=rule) - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-detail", args=[post.pk])) self.assertEquals(response.status_code, 403) + + def test_mark_read(self): + rule = CollectionRuleFactory( + user=self.user, category=CategoryFactory(user=self.user) + ) + post = PostFactory(rule=rule, read=False) + + response = self.client.patch( + reverse("api:posts-detail", args=[post.pk]), + data=json.dumps({"read": True}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["read"], True) + + def test_mark_unread(self): + rule = CollectionRuleFactory( + user=self.user, category=CategoryFactory(user=self.user) + ) + post = PostFactory(rule=rule, read=True) + + response = self.client.patch( + reverse("api:posts-detail", args=[post.pk]), + data=json.dumps({"read": False}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["read"], False) diff --git a/src/newsreader/news/core/tests/endpoints/post/list/tests.py b/src/newsreader/news/core/tests/endpoints/post/list/tests.py index 724d8b2..013decd 100644 --- a/src/newsreader/news/core/tests/endpoints/post/list/tests.py +++ b/src/newsreader/news/core/tests/endpoints/post/list/tests.py @@ -12,10 +12,8 @@ from newsreader.news.core.tests.factories import CategoryFactory, PostFactory class PostListViewTestCase(TestCase): def setUp(self): - self.maxDiff = None - - self.client = Client() self.user = UserFactory(is_staff=True, password="test") + self.client.login(email=self.user.email, password="test") def test_simple(self): rule = CollectionRuleFactory( @@ -23,7 +21,6 @@ class PostListViewTestCase(TestCase): ) PostFactory.create_batch(size=3, rule=rule) - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-list")) data = response.json() @@ -61,7 +58,6 @@ class PostListViewTestCase(TestCase): ), ] - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-list")) data = response.json() @@ -81,7 +77,6 @@ class PostListViewTestCase(TestCase): PostFactory.create_batch(size=80, rule=rule) page_size = 50 - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-list"), {"count": 50}) data = response.json() @@ -90,7 +85,6 @@ class PostListViewTestCase(TestCase): self.assertEquals(len(data["results"]), page_size) def test_empty(self): - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-list")) data = response.json() @@ -102,7 +96,6 @@ class PostListViewTestCase(TestCase): self.assertEquals(len(data["results"]), 0) def test_post(self): - self.client.force_login(self.user) response = self.client.post(reverse("api:posts-list")) data = response.json() @@ -110,7 +103,6 @@ class PostListViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "POST" not allowed.') def test_patch(self): - self.client.force_login(self.user) response = self.client.patch(reverse("api:posts-list")) data = response.json() @@ -118,7 +110,6 @@ class PostListViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "PATCH" not allowed.') def test_put(self): - self.client.force_login(self.user) response = self.client.put(reverse("api:posts-list")) data = response.json() @@ -126,7 +117,6 @@ class PostListViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "PUT" not allowed.') def test_delete(self): - self.client.force_login(self.user) response = self.client.delete(reverse("api:posts-list")) data = response.json() @@ -134,6 +124,8 @@ class PostListViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "DELETE" not allowed.') def test_posts_with_unauthenticated_user_without_category(self): + self.client.logout() + PostFactory.create_batch(size=3, rule=CollectionRuleFactory(user=self.user)) response = self.client.get(reverse("api:posts-list")) @@ -141,6 +133,8 @@ class PostListViewTestCase(TestCase): self.assertEquals(response.status_code, 403) def test_posts_with_unauthenticated_user_with_category(self): + self.client.logout() + category = CategoryFactory(user=self.user) PostFactory.create_batch( @@ -157,7 +151,6 @@ class PostListViewTestCase(TestCase): rule = CollectionRuleFactory(user=other_user, category=None) PostFactory.create_batch(size=3, rule=rule) - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-list")) data = response.json() @@ -173,7 +166,6 @@ class PostListViewTestCase(TestCase): size=3, rule=CollectionRuleFactory(user=other_user, category=category) ) - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-list")) data = response.json() @@ -191,7 +183,6 @@ class PostListViewTestCase(TestCase): ) PostFactory.create_batch(size=3, rule=rule) - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-list")) data = response.json() @@ -201,12 +192,9 @@ class PostListViewTestCase(TestCase): self.assertEquals(data["count"], 0) def test_posts_with_authorized_user_without_category(self): - UserFactory() - rule = CollectionRuleFactory(user=self.user, category=None) PostFactory.create_batch(size=3, rule=rule) - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-list")) data = response.json() @@ -214,3 +202,41 @@ class PostListViewTestCase(TestCase): self.assertTrue("results" in data) self.assertTrue("count" in data) self.assertEquals(data["count"], 3) + + def test_unread_posts(self): + rule = CollectionRuleFactory( + user=self.user, category=CategoryFactory(user=self.user) + ) + + PostFactory.create_batch(size=10, rule=rule, read=False) + PostFactory.create_batch(size=10, rule=rule, read=True) + + response = self.client.get(reverse("api:posts-list"), {"read": "false"}) + + data = response.json() + posts = data["results"] + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 10) + + for post in posts: + self.assertEquals(post["read"], False) + + def test_read_posts(self): + rule = CollectionRuleFactory( + user=self.user, category=CategoryFactory(user=self.user) + ) + + PostFactory.create_batch(size=20, rule=rule, read=False) + PostFactory.create_batch(size=10, rule=rule, read=True) + + response = self.client.get(reverse("api:posts-list"), {"read": "true"}) + + data = response.json() + posts = data["results"] + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 10) + + for post in posts: + self.assertEquals(post["read"], True) diff --git a/src/newsreader/news/core/tests/factories.py b/src/newsreader/news/core/tests/factories.py index 3ccf52d..46eeeae 100644 --- a/src/newsreader/news/core/tests/factories.py +++ b/src/newsreader/news/core/tests/factories.py @@ -25,5 +25,7 @@ class PostFactory(factory.django.DjangoModelFactory): "newsreader.news.collection.tests.factories.CollectionRuleFactory" ) + read = False + class Meta: model = Post diff --git a/src/newsreader/news/core/urls.py b/src/newsreader/news/core/urls.py index c5ccaa9..3255cee 100644 --- a/src/newsreader/news/core/urls.py +++ b/src/newsreader/news/core/urls.py @@ -1,18 +1,34 @@ +from django.contrib.auth.decorators import login_required from django.urls import path -from newsreader.news.core.views import ( - DetailCategoryAPIView, - DetailPostAPIView, - ListCategoryAPIView, - ListPostAPIView, +from newsreader.news.core.endpoints import ( + CategoryReadView, + DetailCategoryView, + DetailPostView, + ListCategoryView, + ListPostView, + NestedPostCategoryView, + NestedRuleCategoryView, ) +from newsreader.news.core.views import MainView +index_page = login_required(MainView.as_view()) + endpoints = [ - path("posts/", ListPostAPIView.as_view(), name="posts-list"), - path("posts//", DetailPostAPIView.as_view(), name="posts-detail"), - path("categories/", ListCategoryAPIView.as_view(), name="categories-list"), + path("posts/", ListPostView.as_view(), name="posts-list"), + path("posts//", DetailPostView.as_view(), name="posts-detail"), + path("categories/", ListCategoryView.as_view(), name="categories-list"), + path("categories//", DetailCategoryView.as_view(), name="categories-detail"), + path("categories//read/", CategoryReadView.as_view(), name="categories-read"), path( - "categories//", DetailCategoryAPIView.as_view(), name="categories-detail" + "categories//rules/", + NestedRuleCategoryView.as_view(), + name="categories-nested-rules", + ), + path( + "categories//posts/", + NestedPostCategoryView.as_view(), + name="categories-nested-posts", ), ] diff --git a/src/newsreader/news/core/views.py b/src/newsreader/news/core/views.py index 415816c..7832673 100644 --- a/src/newsreader/news/core/views.py +++ b/src/newsreader/news/core/views.py @@ -1,49 +1,25 @@ -from django.db.models import Q +from typing import Dict -from rest_framework.generics import ( - ListAPIView, - ListCreateAPIView, - RetrieveUpdateAPIView, - RetrieveUpdateDestroyAPIView, -) -from rest_framework.permissions import IsAuthenticated - -from newsreader.accounts.permissions import IsPostOwner -from newsreader.core.pagination import LargeResultSetPagination, ResultSetPagination -from newsreader.news.core.models import Category, Post -from newsreader.news.core.serializers import CategorySerializer, PostSerializer +from django.views.generic.base import TemplateView -class ListPostAPIView(ListAPIView): - queryset = Post.objects.all() - serializer_class = PostSerializer - pagination_class = LargeResultSetPagination - permission_classes = (IsAuthenticated, IsPostOwner) +class MainView(TemplateView): + template_name = "core/main.html" - def get_queryset(self): + # TODO serialize objects to show filled main page + def get_context_data(self, **kwargs) -> Dict: + context = super().get_context_data(**kwargs) user = self.request.user - initial_queryset = self.queryset.filter(rule__user=user) - return initial_queryset.filter( - Q(rule__category=None) | Q(rule__category__user=user) - ).order_by("rule", "-publication_date", "-created") + categories = { + category: category.rules.order_by("-created") + for category in user.categories.order_by("name") + } -class DetailPostAPIView(RetrieveUpdateAPIView): - queryset = Post.objects.all() - serializer_class = PostSerializer - permission_classes = (IsAuthenticated, IsPostOwner) + rules = { + rule: rule.posts.order_by("-publication_date")[:30] + for rule in user.rules.order_by("-created") + } - -class ListCategoryAPIView(ListCreateAPIView): - queryset = Category.objects.all() - serializer_class = CategorySerializer - pagination_class = ResultSetPagination - - def get_queryset(self): - user = self.request.user - return self.queryset.filter(user=user).order_by("-created", "-modified") - - -class DetailCategoryAPIView(RetrieveUpdateDestroyAPIView): - queryset = Category.objects.all() - serializer_class = CategorySerializer + context.update(categories=categories, rules=rules) + return context diff --git a/src/newsreader/static/src/scss/accounts/components/form/_form.scss b/src/newsreader/scss/accounts/components/form/_form.scss similarity index 94% rename from src/newsreader/static/src/scss/accounts/components/form/_form.scss rename to src/newsreader/scss/accounts/components/form/_form.scss index aa2fb6c..8e6cf7a 100644 --- a/src/newsreader/static/src/scss/accounts/components/form/_form.scss +++ b/src/newsreader/scss/accounts/components/form/_form.scss @@ -8,6 +8,8 @@ &__fieldset { @extend .form__fieldset; + + padding: 10px; } &__fieldset * { diff --git a/src/newsreader/static/src/scss/accounts/components/form/index.scss b/src/newsreader/scss/accounts/components/form/index.scss similarity index 100% rename from src/newsreader/static/src/scss/accounts/components/form/index.scss rename to src/newsreader/scss/accounts/components/form/index.scss diff --git a/src/newsreader/static/src/scss/accounts/components/index.scss b/src/newsreader/scss/accounts/components/index.scss similarity index 100% rename from src/newsreader/static/src/scss/accounts/components/index.scss rename to src/newsreader/scss/accounts/components/index.scss diff --git a/src/newsreader/static/src/scss/accounts/components/main/_main.scss b/src/newsreader/scss/accounts/components/main/_main.scss similarity index 100% rename from src/newsreader/static/src/scss/accounts/components/main/_main.scss rename to src/newsreader/scss/accounts/components/main/_main.scss diff --git a/src/newsreader/static/src/scss/accounts/components/main/index.scss b/src/newsreader/scss/accounts/components/main/index.scss similarity index 100% rename from src/newsreader/static/src/scss/accounts/components/main/index.scss rename to src/newsreader/scss/accounts/components/main/index.scss diff --git a/src/newsreader/static/src/scss/accounts/index.scss b/src/newsreader/scss/accounts/index.scss similarity index 72% rename from src/newsreader/static/src/scss/accounts/index.scss rename to src/newsreader/scss/accounts/index.scss index d0a748c..d155753 100644 --- a/src/newsreader/static/src/scss/accounts/index.scss +++ b/src/newsreader/scss/accounts/index.scss @@ -1,4 +1,6 @@ +// General imports @import "../partials/variables"; @import "../components/index"; +// Page specific @import "./components/index"; diff --git a/src/newsreader/static/src/scss/components/body/_body.scss b/src/newsreader/scss/components/body/_body.scss similarity index 61% rename from src/newsreader/static/src/scss/components/body/_body.scss rename to src/newsreader/scss/components/body/_body.scss index f0829bf..0e2dad3 100644 --- a/src/newsreader/static/src/scss/components/body/_body.scss +++ b/src/newsreader/scss/components/body/_body.scss @@ -2,4 +2,9 @@ margin: 0; padding: 0; background-color: $gainsboro; + + & * { + margin: 0; + padding: 0; + } } diff --git a/src/newsreader/static/src/scss/components/body/index.scss b/src/newsreader/scss/components/body/index.scss similarity index 100% rename from src/newsreader/static/src/scss/components/body/index.scss rename to src/newsreader/scss/components/body/index.scss diff --git a/src/newsreader/static/src/scss/components/button/_button.scss b/src/newsreader/scss/components/button/_button.scss similarity index 95% rename from src/newsreader/static/src/scss/components/button/_button.scss rename to src/newsreader/scss/components/button/_button.scss index 4047b6c..61ddc81 100644 --- a/src/newsreader/static/src/scss/components/button/_button.scss +++ b/src/newsreader/scss/components/button/_button.scss @@ -6,8 +6,6 @@ padding: 10px 50px; - width: 50px; - border: none; border-radius: 2px; diff --git a/src/newsreader/static/src/scss/components/button/index.scss b/src/newsreader/scss/components/button/index.scss similarity index 100% rename from src/newsreader/static/src/scss/components/button/index.scss rename to src/newsreader/scss/components/button/index.scss diff --git a/src/newsreader/static/src/scss/components/error/_error.scss b/src/newsreader/scss/components/error/_error.scss similarity index 100% rename from src/newsreader/static/src/scss/components/error/_error.scss rename to src/newsreader/scss/components/error/_error.scss diff --git a/src/newsreader/static/src/scss/components/error/_errorlist.scss b/src/newsreader/scss/components/error/_errorlist.scss similarity index 100% rename from src/newsreader/static/src/scss/components/error/_errorlist.scss rename to src/newsreader/scss/components/error/_errorlist.scss diff --git a/src/newsreader/static/src/scss/components/error/index.scss b/src/newsreader/scss/components/error/index.scss similarity index 100% rename from src/newsreader/static/src/scss/components/error/index.scss rename to src/newsreader/scss/components/error/index.scss diff --git a/src/newsreader/static/src/scss/components/form/_form.scss b/src/newsreader/scss/components/form/_form.scss similarity index 100% rename from src/newsreader/static/src/scss/components/form/_form.scss rename to src/newsreader/scss/components/form/_form.scss diff --git a/src/newsreader/static/src/scss/components/form/index.scss b/src/newsreader/scss/components/form/index.scss similarity index 100% rename from src/newsreader/static/src/scss/components/form/index.scss rename to src/newsreader/scss/components/form/index.scss diff --git a/src/newsreader/static/src/scss/components/index.scss b/src/newsreader/scss/components/index.scss similarity index 70% rename from src/newsreader/static/src/scss/components/index.scss rename to src/newsreader/scss/components/index.scss index 14ddc8e..a6c0511 100644 --- a/src/newsreader/static/src/scss/components/index.scss +++ b/src/newsreader/scss/components/index.scss @@ -4,3 +4,5 @@ @import "./main/index"; @import "./navbar/index"; @import "./error/index"; +@import "./loading-indicator/index"; +@import "./modal/index"; diff --git a/src/newsreader/static/src/scss/components/input/input.scss b/src/newsreader/scss/components/input/input.scss similarity index 100% rename from src/newsreader/static/src/scss/components/input/input.scss rename to src/newsreader/scss/components/input/input.scss diff --git a/src/newsreader/scss/components/loading-indicator/_loading-indicator.scss b/src/newsreader/scss/components/loading-indicator/_loading-indicator.scss new file mode 100644 index 0000000..0651d1d --- /dev/null +++ b/src/newsreader/scss/components/loading-indicator/_loading-indicator.scss @@ -0,0 +1,41 @@ +.loading-indicator { + display: inline-block; + position: relative; + width: 64px; + height: 64px; + + & div { + display: inline-block; + position: absolute; + left: 6px; + width: 13px; + background-color: $lavendal-pink; + animation: loading-indicator 1.2s cubic-bezier(0, 0.5, 0.5, 1) infinite; + + &:nth-child(1){ + left: 6px; + animation-delay: -0.24s; + } + + &:nth-child(2){ + left: 26px; + animation-delay: -0.12s; + } + + &:nth-child(3){ + left: 45px; + animation-delay: 0; + } + } +} + +@keyframes loading-indicator { + 0% { + top: 6px; + height: 51px; + } + 50%, 100% { + top: 19px; + height: 26px; + } +} diff --git a/src/newsreader/scss/components/loading-indicator/index.scss b/src/newsreader/scss/components/loading-indicator/index.scss new file mode 100644 index 0000000..c3a3bc3 --- /dev/null +++ b/src/newsreader/scss/components/loading-indicator/index.scss @@ -0,0 +1 @@ +@import "loading-indicator"; diff --git a/src/newsreader/static/src/scss/components/main/_main.scss b/src/newsreader/scss/components/main/_main.scss similarity index 100% rename from src/newsreader/static/src/scss/components/main/_main.scss rename to src/newsreader/scss/components/main/_main.scss diff --git a/src/newsreader/static/src/scss/components/main/index.scss b/src/newsreader/scss/components/main/index.scss similarity index 100% rename from src/newsreader/static/src/scss/components/main/index.scss rename to src/newsreader/scss/components/main/index.scss diff --git a/src/newsreader/scss/components/modal/_modal.scss b/src/newsreader/scss/components/modal/_modal.scss new file mode 100644 index 0000000..c4c951f --- /dev/null +++ b/src/newsreader/scss/components/modal/_modal.scss @@ -0,0 +1,9 @@ +.modal { + position: fixed; + + width: 100%; + height: 100%; + top: 0; + + background-color: $dark; +} diff --git a/src/newsreader/scss/components/modal/index.scss b/src/newsreader/scss/components/modal/index.scss new file mode 100644 index 0000000..bcb7d8e --- /dev/null +++ b/src/newsreader/scss/components/modal/index.scss @@ -0,0 +1 @@ +@import "modal"; diff --git a/src/newsreader/static/src/scss/components/navbar/_navbar.scss b/src/newsreader/scss/components/navbar/_navbar.scss similarity index 96% rename from src/newsreader/static/src/scss/components/navbar/_navbar.scss rename to src/newsreader/scss/components/navbar/_navbar.scss index d0ea3b9..b387be0 100644 --- a/src/newsreader/static/src/scss/components/navbar/_navbar.scss +++ b/src/newsreader/scss/components/navbar/_navbar.scss @@ -2,6 +2,7 @@ display: flex; justify-content: center; + padding: 15px 0; width: 100%; background-color: $white; diff --git a/src/newsreader/static/src/scss/components/navbar/index.scss b/src/newsreader/scss/components/navbar/index.scss similarity index 100% rename from src/newsreader/static/src/scss/components/navbar/index.scss rename to src/newsreader/scss/components/navbar/index.scss diff --git a/src/newsreader/scss/homepage/components/categories/_categories.scss b/src/newsreader/scss/homepage/components/categories/_categories.scss new file mode 100644 index 0000000..002a66a --- /dev/null +++ b/src/newsreader/scss/homepage/components/categories/_categories.scss @@ -0,0 +1,24 @@ +.categories { + display: flex; + flex-direction: column; + align-items: center; + + width: 90%; + + font-family: $sidebar-font; + border-radius: 2px; + + & ul { + margin: 0; + padding: 0; + + width: 100%; + + list-style: none; + border-radius: 5px; + } + + &__item { + padding: 2px 10px 5px 10px; + } +} diff --git a/src/newsreader/scss/homepage/components/categories/index.scss b/src/newsreader/scss/homepage/components/categories/index.scss new file mode 100644 index 0000000..0eebf91 --- /dev/null +++ b/src/newsreader/scss/homepage/components/categories/index.scss @@ -0,0 +1 @@ +@import "categories"; diff --git a/src/newsreader/scss/homepage/components/category/_category.scss b/src/newsreader/scss/homepage/components/category/_category.scss new file mode 100644 index 0000000..b272bb1 --- /dev/null +++ b/src/newsreader/scss/homepage/components/category/_category.scss @@ -0,0 +1,46 @@ +.category { + display: flex; + align-items: center; + + padding: 5px; + + border-radius: 5px; + + &__info { + display: flex; + justify-content: space-between; + + width: 100%; + padding: 0 0 0 20px; + + overflow: hidden; + white-space: nowrap; + + & h4 { + overflow: hidden; + text-overflow: ellipsis; + } + + &:hover { + cursor: pointer; + } + } + + &__menu { + display: flex; + align-items: center; + + &:hover { + cursor: pointer; + } + } + + &:hover { + background-color: darken($gainsboro, 10%); + } + + &--selected { + color: $white; + background-color: darken($gainsboro, 10%); + } +} diff --git a/src/newsreader/scss/homepage/components/category/index.scss b/src/newsreader/scss/homepage/components/category/index.scss new file mode 100644 index 0000000..702bb58 --- /dev/null +++ b/src/newsreader/scss/homepage/components/category/index.scss @@ -0,0 +1 @@ +@import "category"; diff --git a/src/newsreader/scss/homepage/components/content/_content.scss b/src/newsreader/scss/homepage/components/content/_content.scss new file mode 100644 index 0000000..9b9efb9 --- /dev/null +++ b/src/newsreader/scss/homepage/components/content/_content.scss @@ -0,0 +1,7 @@ +.content { + display: flex; + flex-direction: column; + align-items: center; + + margin: 2% 0 0 0; +} diff --git a/src/newsreader/scss/homepage/components/content/index.scss b/src/newsreader/scss/homepage/components/content/index.scss new file mode 100644 index 0000000..b424282 --- /dev/null +++ b/src/newsreader/scss/homepage/components/content/index.scss @@ -0,0 +1 @@ +@import "content"; diff --git a/src/newsreader/scss/homepage/components/index.scss b/src/newsreader/scss/homepage/components/index.scss new file mode 100644 index 0000000..b3891f8 --- /dev/null +++ b/src/newsreader/scss/homepage/components/index.scss @@ -0,0 +1,17 @@ +@import "content/index"; +@import "main/index"; + +@import "sidebar/index"; +@import "categories/index"; +@import "category/index"; + +@import "rules/index"; +@import "rule/index"; + +@import "post-block/index"; +@import "posts-section/index"; +@import "posts/index"; +@import "posts-header/index"; +@import "post/index"; +@import "post-message/index"; +@import "read-button/index"; diff --git a/src/newsreader/scss/homepage/components/main/_main.scss b/src/newsreader/scss/homepage/components/main/_main.scss new file mode 100644 index 0000000..42cb2d5 --- /dev/null +++ b/src/newsreader/scss/homepage/components/main/_main.scss @@ -0,0 +1,12 @@ +.main { + display: flex; + flex-direction: row; + width: 100%; + + margin: 0; + background-color: initial; + + &--centered { + justify-content: center; + } +} diff --git a/src/newsreader/scss/homepage/components/main/index.scss b/src/newsreader/scss/homepage/components/main/index.scss new file mode 100644 index 0000000..bdb4ce0 --- /dev/null +++ b/src/newsreader/scss/homepage/components/main/index.scss @@ -0,0 +1 @@ +@import "main"; diff --git a/src/newsreader/scss/homepage/components/post-block/_post-block.scss b/src/newsreader/scss/homepage/components/post-block/_post-block.scss new file mode 100644 index 0000000..e0d2dcd --- /dev/null +++ b/src/newsreader/scss/homepage/components/post-block/_post-block.scss @@ -0,0 +1,12 @@ +.post-block { + display: flex; + flex-direction: column; + + width: 60%; + margin: 0 0 2% 0; + + font-family: $article-font; + + border-radius: 2px; + background-color: $white; +} diff --git a/src/newsreader/scss/homepage/components/post-block/index.scss b/src/newsreader/scss/homepage/components/post-block/index.scss new file mode 100644 index 0000000..e17b7a9 --- /dev/null +++ b/src/newsreader/scss/homepage/components/post-block/index.scss @@ -0,0 +1 @@ +@import "post-block"; diff --git a/src/newsreader/scss/homepage/components/post-message/_post-message.scss b/src/newsreader/scss/homepage/components/post-message/_post-message.scss new file mode 100644 index 0000000..21c5603 --- /dev/null +++ b/src/newsreader/scss/homepage/components/post-message/_post-message.scss @@ -0,0 +1,25 @@ +.post-message { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + + width: 60%; + height: max-content; + border-radius: 2px; + + font-family: $article-font; + background-color: $white; + + &__message { + font-size: 16px; + } + + &__block { + display: flex; + flex-direction: row; + align-items: center; + + margin: 5px; + } +} diff --git a/src/newsreader/scss/homepage/components/post-message/index.scss b/src/newsreader/scss/homepage/components/post-message/index.scss new file mode 100644 index 0000000..03cf130 --- /dev/null +++ b/src/newsreader/scss/homepage/components/post-message/index.scss @@ -0,0 +1 @@ +@import "post-message"; diff --git a/src/newsreader/scss/homepage/components/post/_post.scss b/src/newsreader/scss/homepage/components/post/_post.scss new file mode 100644 index 0000000..2f8d581 --- /dev/null +++ b/src/newsreader/scss/homepage/components/post/_post.scss @@ -0,0 +1,125 @@ +.post { + display: flex; + flex-direction: column; + align-items: center; + position: relative; + + width: 80%; + max-height: 90%; + + margin: 2% auto 5% auto; + + border-radius: 2px; + + overflow-y: auto; + + background-color: $white; + + &__header { + display: flex; + flex-direction: column; + padding: 20px 0 20px 0; + width: 75%; + + font-family: $header-font; + } + + &__title { + line-height: 1; + + &--read { + color: $gainsboro; + } + } + + &__link { + height: 100%; + + & img { + height: 50%; + } + } + + &__date { + margin: 10px 0 0 0; + font-size: small; + } + + &__body { + display:flex; + flex-direction: column; + + padding: 10px 0 30px 0; + width: 75%; + + line-height: 1.5; + font-family: $article-font; + + & p { + padding: 20px 0 0 0; + } + + & img { + padding: 10px 10px 30px 10px; + + max-width: 70%; + width: inherit; + height: 100%; + + align-self: center; + } + } + + &__close-button { + margin: 1% 2% 0 0; + align-self: flex-end; + + & span { + display: inline-flex; + align-items: center; + margin: 0 0 0 5px; + + & img { + width: 10px; + } + } + + &:hover { + background-color: lighten($gainsboro, +1%); + } + } + + &__meta-info { + display: flex; + flex-direction: column; + + align-self: flex-end; + position: absolute; + top: 25%; + width: 10%; + + font-family: $button-font; + color: $nickel; + + & h5 { + margin: 10px 0 0 0; + padding: 5px 1px 5px 5px; + + border-radius: 5px 0 0 5px; + + background-color: aquamarine; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: center; + } + + & h5:first-child { + background-color: $light-orange; + } + + & h5:last-child { + background-color: $light-green; + } + } +} diff --git a/src/newsreader/scss/homepage/components/post/index.scss b/src/newsreader/scss/homepage/components/post/index.scss new file mode 100644 index 0000000..b31e7bb --- /dev/null +++ b/src/newsreader/scss/homepage/components/post/index.scss @@ -0,0 +1 @@ +@import "post"; diff --git a/src/newsreader/scss/homepage/components/posts-header/_posts-header.scss b/src/newsreader/scss/homepage/components/posts-header/_posts-header.scss new file mode 100644 index 0000000..068c2b3 --- /dev/null +++ b/src/newsreader/scss/homepage/components/posts-header/_posts-header.scss @@ -0,0 +1,21 @@ +.posts-header { + display: flex; + padding: 0 5px 0 0; + + width: 80%; + + &__link { + padding: 0 0 0 5px; + } + + &__title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: small; + + &--read { + color: $gainsboro; + } + } +} diff --git a/src/newsreader/scss/homepage/components/posts-header/index.scss b/src/newsreader/scss/homepage/components/posts-header/index.scss new file mode 100644 index 0000000..451a453 --- /dev/null +++ b/src/newsreader/scss/homepage/components/posts-header/index.scss @@ -0,0 +1 @@ +@import "posts-header"; diff --git a/src/newsreader/scss/homepage/components/posts-section/index.scss b/src/newsreader/scss/homepage/components/posts-section/index.scss new file mode 100644 index 0000000..dc0e29b --- /dev/null +++ b/src/newsreader/scss/homepage/components/posts-section/index.scss @@ -0,0 +1,12 @@ +.posts-section { + display: flex; + flex-direction: column; + + padding: 10px; + + &__name { + padding: 20px 0 10px 0; + + border-top: 4px solid $azureish-white; + } +} diff --git a/src/newsreader/scss/homepage/components/posts/_posts.scss b/src/newsreader/scss/homepage/components/posts/_posts.scss new file mode 100644 index 0000000..0734e77 --- /dev/null +++ b/src/newsreader/scss/homepage/components/posts/_posts.scss @@ -0,0 +1,34 @@ +.posts { + display: flex; + flex-direction: column; + + list-style: none; + + &__item { + display: flex; + justify-content: space-between; + + padding: 10px 0 0px 0; + + border-radius: 2px; + border-bottom: 2px solid $azureish-white; + + &:hover { + cursor: pointer; + background-color: lighten($gainsboro, 10%); + } + + & span { + width: 20%; + + font-size: small; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + &:last-child { + border-bottom: 0; + } + } +} diff --git a/src/newsreader/scss/homepage/components/posts/index.scss b/src/newsreader/scss/homepage/components/posts/index.scss new file mode 100644 index 0000000..66f1811 --- /dev/null +++ b/src/newsreader/scss/homepage/components/posts/index.scss @@ -0,0 +1 @@ +@import "posts"; diff --git a/src/newsreader/scss/homepage/components/read-button/_read-button.scss b/src/newsreader/scss/homepage/components/read-button/_read-button.scss new file mode 100644 index 0000000..a8eab4c --- /dev/null +++ b/src/newsreader/scss/homepage/components/read-button/_read-button.scss @@ -0,0 +1,10 @@ +.read-button { + margin: 20px 0 0 0; + + color: $white; + background-color: $confirm-green; + + &:hover { + background-color: darken($confirm-green, 10%); + } +} diff --git a/src/newsreader/scss/homepage/components/read-button/index.scss b/src/newsreader/scss/homepage/components/read-button/index.scss new file mode 100644 index 0000000..8e49454 --- /dev/null +++ b/src/newsreader/scss/homepage/components/read-button/index.scss @@ -0,0 +1 @@ +@import 'read-button'; diff --git a/src/newsreader/scss/homepage/components/rule/_rule.scss b/src/newsreader/scss/homepage/components/rule/_rule.scss new file mode 100644 index 0000000..dba7124 --- /dev/null +++ b/src/newsreader/scss/homepage/components/rule/_rule.scss @@ -0,0 +1,10 @@ +.rule { + display: flex; + width: 80%; + + &__title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/src/newsreader/scss/homepage/components/rule/index.scss b/src/newsreader/scss/homepage/components/rule/index.scss new file mode 100644 index 0000000..7ec839a --- /dev/null +++ b/src/newsreader/scss/homepage/components/rule/index.scss @@ -0,0 +1 @@ +@import "rule"; diff --git a/src/newsreader/scss/homepage/components/rules/_rules.scss b/src/newsreader/scss/homepage/components/rules/_rules.scss new file mode 100644 index 0000000..c8b261e --- /dev/null +++ b/src/newsreader/scss/homepage/components/rules/_rules.scss @@ -0,0 +1,29 @@ +.rules { + &__item { + display: flex; + justify-content: space-between; + align-items: center; + + padding: 5px 5px 5px 20px; + + border-radius: 5px; + + & * { + padding: 0 2px 0 2px; + } + + & div { + padding: 0; + } + + &:hover { + cursor: pointer; + background-color: darken($gainsboro, 10%); + } + + &--selected { + color: $white; + background-color: darken($gainsboro, 10%); + } + } +} diff --git a/src/newsreader/scss/homepage/components/rules/index.scss b/src/newsreader/scss/homepage/components/rules/index.scss new file mode 100644 index 0000000..e6a0ebf --- /dev/null +++ b/src/newsreader/scss/homepage/components/rules/index.scss @@ -0,0 +1 @@ +@import "rules"; diff --git a/src/newsreader/scss/homepage/components/sidebar/_sidebar.scss b/src/newsreader/scss/homepage/components/sidebar/_sidebar.scss new file mode 100644 index 0000000..5c6575b --- /dev/null +++ b/src/newsreader/scss/homepage/components/sidebar/_sidebar.scss @@ -0,0 +1,11 @@ +.sidebar { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + align-self: flex-start; + + position: sticky; + top: 5%; + width: 20%; +} diff --git a/src/newsreader/scss/homepage/components/sidebar/index.scss b/src/newsreader/scss/homepage/components/sidebar/index.scss new file mode 100644 index 0000000..0abffa8 --- /dev/null +++ b/src/newsreader/scss/homepage/components/sidebar/index.scss @@ -0,0 +1 @@ +@import "sidebar"; diff --git a/src/newsreader/scss/homepage/elements/badge/_badge.scss b/src/newsreader/scss/homepage/elements/badge/_badge.scss new file mode 100644 index 0000000..6abab65 --- /dev/null +++ b/src/newsreader/scss/homepage/elements/badge/_badge.scss @@ -0,0 +1,15 @@ +.badge { + display: inline-block; + + padding-left: 8px; + padding-right: 8px; + border-radius: 20%; + + text-align: center; + + background-color: darken($pink, 10%); + color: $white; + + font-family: $button-font; + font-size: small; +} diff --git a/src/newsreader/scss/homepage/elements/badge/index.scss b/src/newsreader/scss/homepage/elements/badge/index.scss new file mode 100644 index 0000000..87110f0 --- /dev/null +++ b/src/newsreader/scss/homepage/elements/badge/index.scss @@ -0,0 +1 @@ +@import "badge"; diff --git a/src/newsreader/scss/homepage/elements/index.scss b/src/newsreader/scss/homepage/elements/index.scss new file mode 100644 index 0000000..66bce62 --- /dev/null +++ b/src/newsreader/scss/homepage/elements/index.scss @@ -0,0 +1 @@ +@import "badge/index"; diff --git a/src/newsreader/scss/homepage/index.scss b/src/newsreader/scss/homepage/index.scss new file mode 100644 index 0000000..aad2761 --- /dev/null +++ b/src/newsreader/scss/homepage/index.scss @@ -0,0 +1,7 @@ +// General imports +@import "../partials/variables"; +@import "../components/index"; + +// Page specific +@import "./components/index"; +@import "./elements/index"; diff --git a/src/newsreader/scss/partials/_variables.scss b/src/newsreader/scss/partials/_variables.scss new file mode 100644 index 0000000..f3a2c77 --- /dev/null +++ b/src/newsreader/scss/partials/_variables.scss @@ -0,0 +1,42 @@ +@import url("https://fonts.googleapis.com/css?family=IBM+Plex+Sans:600&display=swap"); +@import url("https://fonts.googleapis.com/css?family=Barlow&display=swap"); +@import url("https://fonts.googleapis.com/css?family=Open+Sans&display=swap"); +@import url("https://fonts.googleapis.com/css?family=Oswald&display=swap"); +@import url("https://fonts.googleapis.com/css?family=Nunito&display=swap"); + + +$button-font: "IBM Plex Sans", sans-serif; +$form-font: "Barlow", sans-serif; +$article-font: "Open Sans", sans-serif; +$header-font: "Oswald", sans-serif; +$sidebar-font: "Nunito", sans-serif; + +/* colors */ +$white: rgba(255, 255, 255, 1); + +$confirm-green: rgba(46,204,113, 1); +$error-red: rgba(231,76,60, 1); + +// light blue +$azureish-white: rgba(205, 230, 245, 1); + +// dark blue +$pewter-blue: rgba(141, 167, 190, 1); + +// light gray +$gainsboro: rgba(224, 221, 220, 1); + +// medium gray +$roman-silver: rgba(135, 145, 158, 1); + +//dark gray +$nickel: rgba(112, 112, 120, 1); + +$pink: rgba(235, 229, 229, 1); +$lavendal-pink: rgba(162, 155, 254, 1); + +$light-green: rgba(230, 247, 185, 1); +$light-orange: rgba(237, 212, 178, 1); +$light-red: rgba(255, 118, 117, 1); + +$dark: rgba(0, 0, 0, 0.4); diff --git a/src/newsreader/static/icons/angle-down.svg b/src/newsreader/static/icons/angle-down.svg new file mode 100644 index 0000000..1462342 --- /dev/null +++ b/src/newsreader/static/icons/angle-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/newsreader/static/icons/angle-right.svg b/src/newsreader/static/icons/angle-right.svg new file mode 100644 index 0000000..ec7fbe9 --- /dev/null +++ b/src/newsreader/static/icons/angle-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/newsreader/static/icons/arrow-left.svg b/src/newsreader/static/icons/arrow-left.svg new file mode 100644 index 0000000..88526e8 --- /dev/null +++ b/src/newsreader/static/icons/arrow-left.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/newsreader/static/icons/chevron-down.svg b/src/newsreader/static/icons/chevron-down.svg new file mode 100644 index 0000000..211fd17 --- /dev/null +++ b/src/newsreader/static/icons/chevron-down.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/newsreader/static/icons/chevron-right.svg b/src/newsreader/static/icons/chevron-right.svg new file mode 100644 index 0000000..ea2b601 --- /dev/null +++ b/src/newsreader/static/icons/chevron-right.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/newsreader/static/icons/link.svg b/src/newsreader/static/icons/link.svg new file mode 100644 index 0000000..57caa9f --- /dev/null +++ b/src/newsreader/static/icons/link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/newsreader/static/icons/times.svg b/src/newsreader/static/icons/times.svg new file mode 100644 index 0000000..571a32a --- /dev/null +++ b/src/newsreader/static/icons/times.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/newsreader/static/src/scss/partials/_variables.scss b/src/newsreader/static/src/scss/partials/_variables.scss deleted file mode 100644 index d983d2a..0000000 --- a/src/newsreader/static/src/scss/partials/_variables.scss +++ /dev/null @@ -1,26 +0,0 @@ -@import url("https://fonts.googleapis.com/css?family=IBM+Plex+Sans:600&display=swap"); -@import url('https://fonts.googleapis.com/css?family=Barlow&display=swap'); - -$button-font: "IBM Plex Sans", sans-serif; -$form-font: "Barlow", sans-serif; - -/* colors */ -$white: rgba(255, 255, 255, 1); - -$confirm-green: rgba(46,204,113, 1); -$error-red: rgba(231,76,60, 1); - -// light blue -$azureish-white: rgba(205, 230, 245, 1); - -// dark blue -$pewter-blue: rgba(141, 167, 190, 1); - -// light gray -$gainsboro: rgba(224, 221, 220, 1); - -// medium gray -$roman-silver: rgba(135, 145, 158, 1); - -//dark gray -$nickel: rgba(112, 112, 120, 1); diff --git a/src/newsreader/templates/base.html b/src/newsreader/templates/base.html index 460ab8f..8163fa6 100644 --- a/src/newsreader/templates/base.html +++ b/src/newsreader/templates/base.html @@ -9,7 +9,9 @@ {% if messages %} -
      +
        {% for message in messages %} - + {{ message }} {% endfor %} diff --git a/src/newsreader/urls.py b/src/newsreader/urls.py index 77568d3..be59ad1 100644 --- a/src/newsreader/urls.py +++ b/src/newsreader/urls.py @@ -7,7 +7,7 @@ from rest_framework_swagger.views import get_swagger_view from newsreader.accounts.urls import urlpatterns as login_urls from newsreader.news.collection.urls import endpoints as collection_endpoints from newsreader.news.core.urls import endpoints as core_endpoints -from newsreader.news.core.urls import index_page +from newsreader.news.core.urls import urlpatterns schema_view = get_swagger_view(title="Newsreader API") @@ -18,7 +18,7 @@ endpoints = [ ] urlpatterns = [ - path("", index_page, name="index"), + path("", include(urlpatterns)), path("accounts/", include((login_urls, "accounts")), name="accounts"), path("admin/", admin.site.urls, name="admin"), path("api/", include((endpoints, "api")), name="api"), From 94f4ed63278d9d42cb2089c75be241f4eeffcedf Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 24 Nov 2019 16:41:46 +0100 Subject: [PATCH 026/422] Redux refactor --- .../categories/components/CategoryCard.js | 2 +- .../js/pages/homepage/actions/categories.js | 17 ++- .../js/pages/homepage/actions/posts.js | 64 +++++------ .../js/pages/homepage/actions/rules.js | 10 +- .../js/pages/homepage/actions/selected.js | 41 ++++--- .../js/pages/homepage/components/PostModal.js | 6 +- .../homepage/components/feedlist/FeedList.js | 13 +-- .../homepage/components/feedlist/PostItem.js | 2 +- .../homepage/components/feedlist/RuleItem.js | 1 - .../homepage/components/feedlist/filters.js | 18 ++-- .../components/sidebar/CategoryItem.js | 17 ++- .../homepage/components/sidebar/RuleItem.js | 31 +++--- .../homepage/components/sidebar/Sidebar.js | 1 - .../homepage/components/sidebar/functions.js | 7 ++ src/newsreader/js/pages/homepage/constants.js | 2 + .../js/pages/homepage/reducers/categories.js | 101 +++++++----------- .../js/pages/homepage/reducers/posts.js | 22 ++-- .../js/pages/homepage/reducers/rules.js | 62 +++++++---- .../js/pages/homepage/reducers/selected.js | 34 ++++-- .../news/core/templates/core/category.html | 2 +- .../scss/components/body/_body.scss | 2 + src/newsreader/scss/elements/a/_a.scss | 3 + src/newsreader/scss/elements/a/index.scss | 1 + .../scss/elements/button/_button.scss | 6 +- src/newsreader/scss/elements/index.scss | 1 + src/newsreader/scss/elements/link/_link.scss | 1 + .../components/category/_category.scss | 5 +- .../components/post-block/_post-block.scss | 2 +- .../pages/homepage/components/post/_post.scss | 7 +- .../posts-header/_posts-header.scss | 6 +- .../homepage/components/posts/_posts.scss | 7 +- .../pages/homepage/components/rule/_rule.scss | 5 + .../homepage/components/rules/_rules.scss | 5 +- .../pages/homepage/elements/badge/_badge.scss | 5 +- src/newsreader/scss/partials/_colors.scss | 3 +- src/newsreader/static/icons/alarm.svg | 6 ++ src/newsreader/static/icons/apartment.svg | 30 ++++++ .../static/icons/arrow-down-circle.svg | 7 ++ src/newsreader/static/icons/arrow-down.svg | 6 ++ .../static/icons/arrow-left-circle.svg | 7 ++ src/newsreader/static/icons/arrow-left.svg | 12 +-- .../static/icons/arrow-right-circle.svg | 7 ++ src/newsreader/static/icons/arrow-right.svg | 6 ++ .../static/icons/arrow-up-circle.svg | 7 ++ src/newsreader/static/icons/arrow-up.svg | 6 ++ src/newsreader/static/icons/bicycle.svg | 9 ++ src/newsreader/static/icons/bold.svg | 8 ++ src/newsreader/static/icons/book.svg | 7 ++ src/newsreader/static/icons/bookmark.svg | 6 ++ src/newsreader/static/icons/briefcase.svg | 6 ++ src/newsreader/static/icons/bubble.svg | 6 ++ src/newsreader/static/icons/bug.svg | 6 ++ src/newsreader/static/icons/bullhorn.svg | 6 ++ src/newsreader/static/icons/bus.svg | 10 ++ src/newsreader/static/icons/calendar-full.svg | 25 +++++ src/newsreader/static/icons/camera-video.svg | 7 ++ src/newsreader/static/icons/camera.svg | 7 ++ src/newsreader/static/icons/car.svg | 8 ++ src/newsreader/static/icons/cart.svg | 8 ++ src/newsreader/static/icons/chart-bars.svg | 9 ++ .../static/icons/checkmark-circle.svg | 7 ++ .../static/icons/chevron-down-circle.svg | 7 ++ src/newsreader/static/icons/chevron-down.svg | 12 +-- .../static/icons/chevron-left-circle.svg | 7 ++ src/newsreader/static/icons/chevron-left.svg | 6 ++ .../static/icons/chevron-right-circle.svg | 7 ++ src/newsreader/static/icons/chevron-right.svg | 12 +-- .../static/icons/chevron-up-circle.svg | 7 ++ src/newsreader/static/icons/chevron-up.svg | 6 ++ src/newsreader/static/icons/circle-minus.svg | 7 ++ src/newsreader/static/icons/clock.svg | 7 ++ src/newsreader/static/icons/cloud-check.svg | 7 ++ .../static/icons/cloud-download.svg | 7 ++ src/newsreader/static/icons/cloud-sync.svg | 8 ++ src/newsreader/static/icons/cloud-upload.svg | 7 ++ src/newsreader/static/icons/cloud.svg | 6 ++ src/newsreader/static/icons/code.svg | 8 ++ src/newsreader/static/icons/coffee-cup.svg | 7 ++ src/newsreader/static/icons/cog.svg | 7 ++ src/newsreader/static/icons/construction.svg | 6 ++ src/newsreader/static/icons/crop.svg | 9 ++ src/newsreader/static/icons/cross-circle.svg | 7 ++ src/newsreader/static/icons/cross.svg | 6 ++ src/newsreader/static/icons/database.svg | 6 ++ src/newsreader/static/icons/diamond.svg | 6 ++ src/newsreader/static/icons/dice.svg | 12 +++ src/newsreader/static/icons/dinner.svg | 7 ++ src/newsreader/static/icons/direction-ltr.svg | 7 ++ src/newsreader/static/icons/direction-rtl.svg | 7 ++ src/newsreader/static/icons/download.svg | 7 ++ src/newsreader/static/icons/drop.svg | 6 ++ src/newsreader/static/icons/earth.svg | 6 ++ src/newsreader/static/icons/enter-down.svg | 7 ++ src/newsreader/static/icons/enter.svg | 7 ++ src/newsreader/static/icons/envelope.svg | 6 ++ src/newsreader/static/icons/exit-up.svg | 7 ++ src/newsreader/static/icons/exit.svg | 7 ++ src/newsreader/static/icons/eye.svg | 6 ++ src/newsreader/static/icons/file-add.svg | 7 ++ src/newsreader/static/icons/file-empty.svg | 6 ++ src/newsreader/static/icons/film-play.svg | 7 ++ src/newsreader/static/icons/flag.svg | 7 ++ .../static/icons/frame-contract.svg | 9 ++ src/newsreader/static/icons/frame-expand.svg | 9 ++ src/newsreader/static/icons/funnel.svg | 6 ++ src/newsreader/static/icons/gift.svg | 6 ++ .../static/icons/graduation-hat.svg | 6 ++ src/newsreader/static/icons/hand.svg | 6 ++ src/newsreader/static/icons/heart-pulse.svg | 8 ++ src/newsreader/static/icons/heart.svg | 6 ++ src/newsreader/static/icons/highlight.svg | 6 ++ src/newsreader/static/icons/history.svg | 7 ++ src/newsreader/static/icons/home.svg | 6 ++ src/newsreader/static/icons/hourglass.svg | 7 ++ src/newsreader/static/icons/inbox.svg | 6 ++ .../static/icons/indent-decrease.svg | 11 ++ .../static/icons/indent-increase.svg | 11 ++ src/newsreader/static/icons/italic.svg | 6 ++ src/newsreader/static/icons/keyboard.svg | 27 +++++ src/newsreader/static/icons/laptop-phone.svg | 9 ++ src/newsreader/static/icons/laptop.svg | 7 ++ src/newsreader/static/icons/layers.svg | 8 ++ src/newsreader/static/icons/leaf.svg | 6 ++ src/newsreader/static/icons/license.svg | 12 +++ src/newsreader/static/icons/lighter.svg | 7 ++ src/newsreader/static/icons/line-spacing.svg | 10 ++ src/newsreader/static/icons/linearicons.svg | 7 ++ src/newsreader/static/icons/link.svg | 8 +- src/newsreader/static/icons/list.svg | 11 ++ src/newsreader/static/icons/location.svg | 6 ++ src/newsreader/static/icons/lock.svg | 6 ++ src/newsreader/static/icons/magic-wand.svg | 10 ++ src/newsreader/static/icons/magnifier.svg | 6 ++ src/newsreader/static/icons/map-marker.svg | 7 ++ src/newsreader/static/icons/map.svg | 6 ++ src/newsreader/static/icons/menu-circle.svg | 9 ++ src/newsreader/static/icons/menu.svg | 8 ++ src/newsreader/static/icons/mic.svg | 7 ++ src/newsreader/static/icons/moon.svg | 6 ++ src/newsreader/static/icons/move.svg | 6 ++ src/newsreader/static/icons/music-note.svg | 6 ++ src/newsreader/static/icons/mustache.svg | 9 ++ src/newsreader/static/icons/neutral.svg | 9 ++ src/newsreader/static/icons/page-break.svg | 14 +++ src/newsreader/static/icons/paperclip.svg | 6 ++ src/newsreader/static/icons/paw.svg | 10 ++ src/newsreader/static/icons/pencil.svg | 6 ++ src/newsreader/static/icons/phone-handset.svg | 6 ++ src/newsreader/static/icons/phone.svg | 8 ++ src/newsreader/static/icons/picture.svg | 8 ++ src/newsreader/static/icons/pie-chart.svg | 7 ++ src/newsreader/static/icons/pilcrow.svg | 6 ++ src/newsreader/static/icons/plus-circle.svg | 7 ++ src/newsreader/static/icons/pointer-down.svg | 6 ++ src/newsreader/static/icons/pointer-left.svg | 6 ++ src/newsreader/static/icons/pointer-right.svg | 6 ++ src/newsreader/static/icons/pointer-up.svg | 6 ++ src/newsreader/static/icons/poop.svg | 6 ++ src/newsreader/static/icons/power-switch.svg | 7 ++ src/newsreader/static/icons/printer.svg | 10 ++ src/newsreader/static/icons/pushpin.svg | 6 ++ .../static/icons/question-circle.svg | 8 ++ src/newsreader/static/icons/redo.svg | 6 ++ src/newsreader/static/icons/rocket.svg | 8 ++ src/newsreader/static/icons/sad.svg | 9 ++ src/newsreader/static/icons/screen.svg | 6 ++ src/newsreader/static/icons/select.svg | 7 ++ src/newsreader/static/icons/shirt.svg | 6 ++ src/newsreader/static/icons/smartphone.svg | 8 ++ src/newsreader/static/icons/smile.svg | 9 ++ .../static/icons/sort-alpha-asc.svg | 8 ++ .../static/icons/sort-amount-asc.svg | 10 ++ src/newsreader/static/icons/spell-check.svg | 9 ++ src/newsreader/static/icons/star-empty.svg | 15 +++ src/newsreader/static/icons/star-half.svg | 10 ++ src/newsreader/static/icons/star.svg | 6 ++ src/newsreader/static/icons/store.svg | 10 ++ src/newsreader/static/icons/strikethrough.svg | 8 ++ src/newsreader/static/icons/sun.svg | 14 +++ src/newsreader/static/icons/sync.svg | 7 ++ src/newsreader/static/icons/tablet.svg | 8 ++ src/newsreader/static/icons/tag.svg | 7 ++ .../static/icons/text-align-center.svg | 10 ++ .../static/icons/text-align-justify.svg | 10 ++ .../static/icons/text-align-left.svg | 10 ++ .../static/icons/text-align-right.svg | 10 ++ .../static/icons/text-format-remove.svg | 8 ++ src/newsreader/static/icons/text-format.svg | 7 ++ src/newsreader/static/icons/text-size.svg | 7 ++ src/newsreader/static/icons/thumbs-down.svg | 6 ++ src/newsreader/static/icons/thumbs-up.svg | 6 ++ src/newsreader/static/icons/train.svg | 11 ++ src/newsreader/static/icons/trash.svg | 9 ++ src/newsreader/static/icons/underline.svg | 7 ++ src/newsreader/static/icons/undo.svg | 6 ++ src/newsreader/static/icons/unlink.svg | 13 +++ src/newsreader/static/icons/upload.svg | 7 ++ src/newsreader/static/icons/user.svg | 7 ++ src/newsreader/static/icons/users.svg | 9 ++ src/newsreader/static/icons/volume-high.svg | 9 ++ src/newsreader/static/icons/volume-low.svg | 7 ++ src/newsreader/static/icons/volume-medium.svg | 8 ++ src/newsreader/static/icons/volume.svg | 6 ++ src/newsreader/static/icons/warning.svg | 8 ++ src/newsreader/static/icons/wheelchair.svg | 7 ++ 205 files changed, 1610 insertions(+), 252 deletions(-) create mode 100644 src/newsreader/js/pages/homepage/components/sidebar/functions.js create mode 100644 src/newsreader/js/pages/homepage/constants.js create mode 100644 src/newsreader/scss/elements/a/_a.scss create mode 100644 src/newsreader/scss/elements/a/index.scss create mode 100755 src/newsreader/static/icons/alarm.svg create mode 100755 src/newsreader/static/icons/apartment.svg create mode 100755 src/newsreader/static/icons/arrow-down-circle.svg create mode 100755 src/newsreader/static/icons/arrow-down.svg create mode 100755 src/newsreader/static/icons/arrow-left-circle.svg create mode 100755 src/newsreader/static/icons/arrow-right-circle.svg create mode 100755 src/newsreader/static/icons/arrow-right.svg create mode 100755 src/newsreader/static/icons/arrow-up-circle.svg create mode 100755 src/newsreader/static/icons/arrow-up.svg create mode 100755 src/newsreader/static/icons/bicycle.svg create mode 100755 src/newsreader/static/icons/bold.svg create mode 100755 src/newsreader/static/icons/book.svg create mode 100755 src/newsreader/static/icons/bookmark.svg create mode 100755 src/newsreader/static/icons/briefcase.svg create mode 100755 src/newsreader/static/icons/bubble.svg create mode 100755 src/newsreader/static/icons/bug.svg create mode 100755 src/newsreader/static/icons/bullhorn.svg create mode 100755 src/newsreader/static/icons/bus.svg create mode 100755 src/newsreader/static/icons/calendar-full.svg create mode 100755 src/newsreader/static/icons/camera-video.svg create mode 100755 src/newsreader/static/icons/camera.svg create mode 100755 src/newsreader/static/icons/car.svg create mode 100755 src/newsreader/static/icons/cart.svg create mode 100755 src/newsreader/static/icons/chart-bars.svg create mode 100755 src/newsreader/static/icons/checkmark-circle.svg create mode 100755 src/newsreader/static/icons/chevron-down-circle.svg create mode 100755 src/newsreader/static/icons/chevron-left-circle.svg create mode 100755 src/newsreader/static/icons/chevron-left.svg create mode 100755 src/newsreader/static/icons/chevron-right-circle.svg create mode 100755 src/newsreader/static/icons/chevron-up-circle.svg create mode 100755 src/newsreader/static/icons/chevron-up.svg create mode 100755 src/newsreader/static/icons/circle-minus.svg create mode 100755 src/newsreader/static/icons/clock.svg create mode 100755 src/newsreader/static/icons/cloud-check.svg create mode 100755 src/newsreader/static/icons/cloud-download.svg create mode 100755 src/newsreader/static/icons/cloud-sync.svg create mode 100755 src/newsreader/static/icons/cloud-upload.svg create mode 100755 src/newsreader/static/icons/cloud.svg create mode 100755 src/newsreader/static/icons/code.svg create mode 100755 src/newsreader/static/icons/coffee-cup.svg create mode 100755 src/newsreader/static/icons/cog.svg create mode 100755 src/newsreader/static/icons/construction.svg create mode 100755 src/newsreader/static/icons/crop.svg create mode 100755 src/newsreader/static/icons/cross-circle.svg create mode 100755 src/newsreader/static/icons/cross.svg create mode 100755 src/newsreader/static/icons/database.svg create mode 100755 src/newsreader/static/icons/diamond.svg create mode 100755 src/newsreader/static/icons/dice.svg create mode 100755 src/newsreader/static/icons/dinner.svg create mode 100755 src/newsreader/static/icons/direction-ltr.svg create mode 100755 src/newsreader/static/icons/direction-rtl.svg create mode 100755 src/newsreader/static/icons/download.svg create mode 100755 src/newsreader/static/icons/drop.svg create mode 100755 src/newsreader/static/icons/earth.svg create mode 100755 src/newsreader/static/icons/enter-down.svg create mode 100755 src/newsreader/static/icons/enter.svg create mode 100755 src/newsreader/static/icons/envelope.svg create mode 100755 src/newsreader/static/icons/exit-up.svg create mode 100755 src/newsreader/static/icons/exit.svg create mode 100755 src/newsreader/static/icons/eye.svg create mode 100755 src/newsreader/static/icons/file-add.svg create mode 100755 src/newsreader/static/icons/file-empty.svg create mode 100755 src/newsreader/static/icons/film-play.svg create mode 100755 src/newsreader/static/icons/flag.svg create mode 100755 src/newsreader/static/icons/frame-contract.svg create mode 100755 src/newsreader/static/icons/frame-expand.svg create mode 100755 src/newsreader/static/icons/funnel.svg create mode 100755 src/newsreader/static/icons/gift.svg create mode 100755 src/newsreader/static/icons/graduation-hat.svg create mode 100755 src/newsreader/static/icons/hand.svg create mode 100755 src/newsreader/static/icons/heart-pulse.svg create mode 100755 src/newsreader/static/icons/heart.svg create mode 100755 src/newsreader/static/icons/highlight.svg create mode 100755 src/newsreader/static/icons/history.svg create mode 100755 src/newsreader/static/icons/home.svg create mode 100755 src/newsreader/static/icons/hourglass.svg create mode 100755 src/newsreader/static/icons/inbox.svg create mode 100755 src/newsreader/static/icons/indent-decrease.svg create mode 100755 src/newsreader/static/icons/indent-increase.svg create mode 100755 src/newsreader/static/icons/italic.svg create mode 100755 src/newsreader/static/icons/keyboard.svg create mode 100755 src/newsreader/static/icons/laptop-phone.svg create mode 100755 src/newsreader/static/icons/laptop.svg create mode 100755 src/newsreader/static/icons/layers.svg create mode 100755 src/newsreader/static/icons/leaf.svg create mode 100755 src/newsreader/static/icons/license.svg create mode 100755 src/newsreader/static/icons/lighter.svg create mode 100755 src/newsreader/static/icons/line-spacing.svg create mode 100755 src/newsreader/static/icons/linearicons.svg create mode 100755 src/newsreader/static/icons/list.svg create mode 100755 src/newsreader/static/icons/location.svg create mode 100755 src/newsreader/static/icons/lock.svg create mode 100755 src/newsreader/static/icons/magic-wand.svg create mode 100755 src/newsreader/static/icons/magnifier.svg create mode 100755 src/newsreader/static/icons/map-marker.svg create mode 100755 src/newsreader/static/icons/map.svg create mode 100755 src/newsreader/static/icons/menu-circle.svg create mode 100755 src/newsreader/static/icons/menu.svg create mode 100755 src/newsreader/static/icons/mic.svg create mode 100755 src/newsreader/static/icons/moon.svg create mode 100755 src/newsreader/static/icons/move.svg create mode 100755 src/newsreader/static/icons/music-note.svg create mode 100755 src/newsreader/static/icons/mustache.svg create mode 100755 src/newsreader/static/icons/neutral.svg create mode 100755 src/newsreader/static/icons/page-break.svg create mode 100755 src/newsreader/static/icons/paperclip.svg create mode 100755 src/newsreader/static/icons/paw.svg create mode 100755 src/newsreader/static/icons/pencil.svg create mode 100755 src/newsreader/static/icons/phone-handset.svg create mode 100755 src/newsreader/static/icons/phone.svg create mode 100755 src/newsreader/static/icons/picture.svg create mode 100755 src/newsreader/static/icons/pie-chart.svg create mode 100755 src/newsreader/static/icons/pilcrow.svg create mode 100755 src/newsreader/static/icons/plus-circle.svg create mode 100755 src/newsreader/static/icons/pointer-down.svg create mode 100755 src/newsreader/static/icons/pointer-left.svg create mode 100755 src/newsreader/static/icons/pointer-right.svg create mode 100755 src/newsreader/static/icons/pointer-up.svg create mode 100755 src/newsreader/static/icons/poop.svg create mode 100755 src/newsreader/static/icons/power-switch.svg create mode 100755 src/newsreader/static/icons/printer.svg create mode 100755 src/newsreader/static/icons/pushpin.svg create mode 100755 src/newsreader/static/icons/question-circle.svg create mode 100755 src/newsreader/static/icons/redo.svg create mode 100755 src/newsreader/static/icons/rocket.svg create mode 100755 src/newsreader/static/icons/sad.svg create mode 100755 src/newsreader/static/icons/screen.svg create mode 100755 src/newsreader/static/icons/select.svg create mode 100755 src/newsreader/static/icons/shirt.svg create mode 100755 src/newsreader/static/icons/smartphone.svg create mode 100755 src/newsreader/static/icons/smile.svg create mode 100755 src/newsreader/static/icons/sort-alpha-asc.svg create mode 100755 src/newsreader/static/icons/sort-amount-asc.svg create mode 100755 src/newsreader/static/icons/spell-check.svg create mode 100755 src/newsreader/static/icons/star-empty.svg create mode 100755 src/newsreader/static/icons/star-half.svg create mode 100755 src/newsreader/static/icons/star.svg create mode 100755 src/newsreader/static/icons/store.svg create mode 100755 src/newsreader/static/icons/strikethrough.svg create mode 100755 src/newsreader/static/icons/sun.svg create mode 100755 src/newsreader/static/icons/sync.svg create mode 100755 src/newsreader/static/icons/tablet.svg create mode 100755 src/newsreader/static/icons/tag.svg create mode 100755 src/newsreader/static/icons/text-align-center.svg create mode 100755 src/newsreader/static/icons/text-align-justify.svg create mode 100755 src/newsreader/static/icons/text-align-left.svg create mode 100755 src/newsreader/static/icons/text-align-right.svg create mode 100755 src/newsreader/static/icons/text-format-remove.svg create mode 100755 src/newsreader/static/icons/text-format.svg create mode 100755 src/newsreader/static/icons/text-size.svg create mode 100755 src/newsreader/static/icons/thumbs-down.svg create mode 100755 src/newsreader/static/icons/thumbs-up.svg create mode 100755 src/newsreader/static/icons/train.svg create mode 100755 src/newsreader/static/icons/trash.svg create mode 100755 src/newsreader/static/icons/underline.svg create mode 100755 src/newsreader/static/icons/undo.svg create mode 100755 src/newsreader/static/icons/unlink.svg create mode 100755 src/newsreader/static/icons/upload.svg create mode 100755 src/newsreader/static/icons/user.svg create mode 100755 src/newsreader/static/icons/users.svg create mode 100755 src/newsreader/static/icons/volume-high.svg create mode 100755 src/newsreader/static/icons/volume-low.svg create mode 100755 src/newsreader/static/icons/volume-medium.svg create mode 100755 src/newsreader/static/icons/volume.svg create mode 100755 src/newsreader/static/icons/warning.svg create mode 100755 src/newsreader/static/icons/wheelchair.svg diff --git a/src/newsreader/js/pages/categories/components/CategoryCard.js b/src/newsreader/js/pages/categories/components/CategoryCard.js index df5df70..04fccef 100644 --- a/src/newsreader/js/pages/categories/components/CategoryCard.js +++ b/src/newsreader/js/pages/categories/components/CategoryCard.js @@ -6,7 +6,7 @@ const CategoryCard = props => { const { category } = props; const categoryRules = category.rules.map(rule => { - const faviconUrl = rule.favicon ? rule.favicon : '/static/favicon-placeholder.svg'; + const faviconUrl = rule.favicon ? rule.favicon : '/static/picture.svg'; return (
      • diff --git a/src/newsreader/js/pages/homepage/actions/categories.js b/src/newsreader/js/pages/homepage/actions/categories.js index 07a1066..85772a6 100644 --- a/src/newsreader/js/pages/homepage/actions/categories.js +++ b/src/newsreader/js/pages/homepage/actions/categories.js @@ -1,5 +1,7 @@ import { requestRules, receiveRules, fetchRulesByCategory } from './rules.js'; +import { CATEGORY_TYPE } from '../constants.js'; + export const SELECT_CATEGORY = 'SELECT_CATEGORY'; export const RECEIVE_CATEGORY = 'RECEIVE_CATEGORY'; @@ -10,7 +12,7 @@ export const REQUEST_CATEGORIES = 'REQUEST_CATEGORIES'; export const selectCategory = category => ({ type: SELECT_CATEGORY, - item: category, + section: { ...category, type: CATEGORY_TYPE }, }); export const receiveCategory = category => ({ @@ -18,9 +20,9 @@ export const receiveCategory = category => ({ category, }); -export const receiveCategories = json => ({ +export const receiveCategories = categories => ({ type: RECEIVE_CATEGORIES, - categories: json, + categories, }); export const requestCategory = () => ({ type: REQUEST_CATEGORY }); @@ -70,7 +72,14 @@ export const fetchCategories = () => { }; export const fetchCategory = category => { - return dispatch => { + return (dispatch, getState) => { + const { selected } = getState(); + const selectedSection = { ...selected.item }; + + if (selectedSection.type === CATEGORY_TYPE && selectedSection.clicks <= 1) { + return; + } + dispatch(requestCategory()); return fetch(`/api/categories/${category.id}`) diff --git a/src/newsreader/js/pages/homepage/actions/posts.js b/src/newsreader/js/pages/homepage/actions/posts.js index 9951859..59229ab 100644 --- a/src/newsreader/js/pages/homepage/actions/posts.js +++ b/src/newsreader/js/pages/homepage/actions/posts.js @@ -1,3 +1,5 @@ +import { RULE_TYPE } from '../constants.js'; + export const SELECT_POST = 'SELECT_POST'; export const UNSELECT_POST = 'UNSELECT_POST'; @@ -16,17 +18,15 @@ export const unSelectPost = () => ({ type: UNSELECT_POST, }); -export const postRead = (post, rule, category) => ({ +export const postRead = (post, section) => ({ type: MARK_POST_READ, - category: category, - post: post, - rule: rule, + post, + section, }); export const markPostRead = (post, token) => { return (dispatch, getState) => { const { rules } = getState(); - const { categories } = getState(); const url = `/api/posts/${post.id}/`; const options = { @@ -38,17 +38,16 @@ export const markPostRead = (post, token) => { body: JSON.stringify({ read: true }), }; - const rule = rules.items[post.rule]; - const category = categories.items[rule.category]; + const section = { + ...rules.items[post.rule], + type: RULE_TYPE, + }; return fetch(url, options) .then(response => response.json()) .then(updatedPost => { - const updatedRule = { ...rule, unread: rule.unread - 1 }; - const updatedCategory = { ...category, unread: category.unread - 1 }; - dispatch(receivePost({ ...updatedPost })); - dispatch(postRead({ ...updatedPost }, updatedRule, updatedCategory)); + dispatch(postRead({ ...updatedPost }, section)); }); }; }; @@ -66,37 +65,24 @@ export const receivePost = post => ({ export const requestPosts = () => ({ type: REQUEST_POSTS }); -export const fetchPostsByCategory = (category, page = false) => { +export const fetchPostsBySection = (section, page = false) => { return dispatch => { + if (section.unread === 0) { + return; + } + dispatch(requestPosts()); - const url = page ? page : `/api/categories/${category.id}/posts/?read=false`; - return fetch(url) - .then(response => response.json()) - .then(json => { - const posts = {}; - - json.results.forEach(post => { - posts[post.id] = post; - }); - - dispatch(receivePosts({ items: posts, next: json.next })); - }) - .catch(error => { - if (error instanceof TypeError) { - console.log(`Unable to parse posts from request: ${error}`); - } - - dispatch(receivePosts({ items: {}, next: null })); - }); - }; -}; - -export const fetchPostsByRule = (rule, page = false) => { - return dispatch => { - dispatch(requestPosts()); - - const url = page ? page : `/api/rules/${rule.id}/posts/?read=false`; + let url = null; + + switch ('category' in section) { + case true: + url = page ? page : `/api/rules/${section.id}/posts/?read=false`; + break; + default: + url = page ? page : `/api/categories/${section.id}/posts/?read=false`; + } + return fetch(url) .then(response => response.json()) .then(json => { diff --git a/src/newsreader/js/pages/homepage/actions/rules.js b/src/newsreader/js/pages/homepage/actions/rules.js index 0b843f6..50c8f95 100644 --- a/src/newsreader/js/pages/homepage/actions/rules.js +++ b/src/newsreader/js/pages/homepage/actions/rules.js @@ -1,4 +1,5 @@ import { fetchCategory } from './categories.js'; +import { RULE_TYPE } from '../constants.js'; export const SELECT_RULE = 'SELECT_RULE'; export const SELECT_RULES = 'SELECT_RULES'; @@ -11,7 +12,7 @@ export const REQUEST_RULES = 'REQUEST_RULES'; export const selectRule = rule => ({ type: SELECT_RULE, - item: rule, + section: { ...rule, type: RULE_TYPE }, }); export const requestRule = () => ({ type: REQUEST_RULE }); @@ -29,6 +30,13 @@ export const receiveRules = rules => ({ export const fetchRule = rule => { return (dispatch, getState) => { + const { selected } = getState(); + const selectedSection = { ...selected.item }; + + if (selectedSection.type === RULE_TYPE && selectedSection.clicks <= 1) { + return; + } + dispatch(requestRule()); const { categories } = getState(); diff --git a/src/newsreader/js/pages/homepage/actions/selected.js b/src/newsreader/js/pages/homepage/actions/selected.js index 95b8603..0a69266 100644 --- a/src/newsreader/js/pages/homepage/actions/selected.js +++ b/src/newsreader/js/pages/homepage/actions/selected.js @@ -1,18 +1,28 @@ import { receiveCategory, requestCategory } from './categories.js'; import { receiveRule, requestRule } from './rules.js'; +import { CATEGORY_TYPE, RULE_TYPE } from '../constants.js'; export const MARK_SECTION_READ = 'MARK_SECTION_READ'; -export const markSectionRead = (category, rule = {}) => ({ - category: category, - rule: rule, +export const markSectionRead = section => ({ type: MARK_SECTION_READ, + section, }); const markCategoryRead = (category, token) => { - return dispatch => { + return (dispatch, getState) => { dispatch(requestCategory(category)); + const { rules } = getState(); + const categoryRules = Object.values({ ...rules.items }).filter(rule => { + return rule.category === category.id; + }); + const ruleMapping = {}; + + categoryRules.forEach(rule => { + ruleMapping[rule.id] = { ...rule }; + }); + const url = `/api/categories/${category.id}/read/`; const options = { method: 'POST', @@ -25,16 +35,20 @@ const markCategoryRead = (category, token) => { .then(response => response.json()) .then(updatedCategory => { dispatch(receiveCategory({ ...updatedCategory })); - dispatch(markSectionRead({ ...category, ...updatedCategory })); + dispatch( + markSectionRead({ + ...category, + ...updatedCategory, + rules: ruleMapping, + type: CATEGORY_TYPE, + }) + ); }); }; }; const markRuleRead = (rule, token) => { return (dispatch, getState) => { - const { categories } = getState(); - const category = categories.items[rule.category]; - dispatch(requestRule(rule)); const url = `/api/rules/${rule.id}/read/`; @@ -51,15 +65,16 @@ const markRuleRead = (rule, token) => { dispatch(receiveRule({ ...updatedRule })); // Use the old rule to decrement category with old unread count - dispatch(markSectionRead({ ...category }, { ...rule })); + dispatch(markSectionRead({ ...rule, type: RULE_TYPE })); }); }; }; export const markRead = (selected, token) => { - if ('category' in selected) { - return markRuleRead(selected, token); - } else { - return markCategoryRead(selected, token); + switch ('category' in selected) { + case true: + return markRuleRead(selected, token); + default: + return markCategoryRead(selected, token); } }; diff --git a/src/newsreader/js/pages/homepage/components/PostModal.js b/src/newsreader/js/pages/homepage/components/PostModal.js index 9e40a89..524a217 100644 --- a/src/newsreader/js/pages/homepage/components/PostModal.js +++ b/src/newsreader/js/pages/homepage/components/PostModal.js @@ -14,7 +14,7 @@ class PostModal extends React.Component { const token = Cookies.get('csrftoken'); if (!post.read) { - this.readTimer = setTimeout(markPostRead, 30000, post, token); + this.readTimer = setTimeout(markPostRead, 3000, post, token); } } @@ -40,7 +40,7 @@ class PostModal extends React.Component { > Close{' '} - +
        @@ -52,7 +52,7 @@ class PostModal extends React.Component { target="_blank" rel="noopener noreferrer" > - +

    {publicationDate} diff --git a/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js b/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js index d7bc94c..36966c8 100644 --- a/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js +++ b/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js @@ -2,7 +2,7 @@ import React from 'react'; import { connect } from 'react-redux'; import { isEqual } from 'lodash'; -import { fetchPostsByRule, fetchPostsByCategory } from '../../actions/posts.js'; +import { fetchPostsBySection } from '../../actions/posts.js'; import { filterPosts } from './filters.js'; import LoadingIndicator from '../../../../components/LoadingIndicator.js'; @@ -33,11 +33,7 @@ class FeedList extends React.Component { } paginate() { - if ('category' in this.props.selected) { - return this.props.fetchPostsByRule(this.props.selected, this.props.next); - } else { - return this.props.fetchPostsByCategory(this.props.selected, this.props.next); - } + this.props.fetchPostsBySection(this.props.selected, this.props.next); } render() { @@ -90,10 +86,7 @@ const mapStateToProps = state => ({ }); const mapDispatchToProps = dispatch => ({ - fetchPostsByRule: (rule, page = false) => dispatch(fetchPostsByRule(rule, page)), - fetchPostsByCategory: (category, page = false) => { - dispatch(fetchPostsByCategory(category, page)); - }, + fetchPostsBySection: (rule, page = false) => dispatch(fetchPostsBySection(rule, page)), }); export default connect( diff --git a/src/newsreader/js/pages/homepage/components/feedlist/PostItem.js b/src/newsreader/js/pages/homepage/components/feedlist/PostItem.js index 3e8a8cb..e226e9b 100644 --- a/src/newsreader/js/pages/homepage/components/feedlist/PostItem.js +++ b/src/newsreader/js/pages/homepage/components/feedlist/PostItem.js @@ -31,7 +31,7 @@ class PostItem extends React.Component { target="_blank" rel="noopener noreferrer" > - + {publicationDate} diff --git a/src/newsreader/js/pages/homepage/components/feedlist/RuleItem.js b/src/newsreader/js/pages/homepage/components/feedlist/RuleItem.js index 08cb3aa..f7a8a9b 100644 --- a/src/newsreader/js/pages/homepage/components/feedlist/RuleItem.js +++ b/src/newsreader/js/pages/homepage/components/feedlist/RuleItem.js @@ -15,7 +15,6 @@ class RuleItem extends React.Component { return (

    {this.props.rule.name}

    - {/* TODO: Add empty posts message */}
      {postItems}
    ); diff --git a/src/newsreader/js/pages/homepage/components/feedlist/filters.js b/src/newsreader/js/pages/homepage/components/feedlist/filters.js index c392775..a3ee886 100644 --- a/src/newsreader/js/pages/homepage/components/feedlist/filters.js +++ b/src/newsreader/js/pages/homepage/components/feedlist/filters.js @@ -1,10 +1,12 @@ +import { CATEGORY_TYPE, RULE_TYPE } from '../../constants.js'; + const isEmpty = (object = {}) => { return Object.keys(object).length === 0; }; export const filterPostsByRule = (rule = {}, posts = []) => { const filteredPosts = posts.filter(post => { - return post.rule === rule.id && !post.read; + return post.rule === rule.id; }); return filteredPosts.length > 0 ? [{ rule, posts: filteredPosts }] : []; @@ -17,7 +19,7 @@ export const filterPostsByCategory = (category = {}, rules = [], posts = []) => const filteredData = filteredRules.map(rule => { const filteredPosts = posts.filter(post => { - return post.rule === rule.id && !post.read; + return post.rule === rule.id; }); return { @@ -32,12 +34,12 @@ export const filterPostsByCategory = (category = {}, rules = [], posts = []) => export const filterPosts = state => { const posts = Object.values({ ...state.posts.items }); - if (!isEmpty(state.selected.item) && !('category' in state.selected.item)) { - const rules = Object.values({ ...state.rules.items }); - - return filterPostsByCategory({ ...state.selected.item }, rules, posts); - } else if ('category' in state.selected.item) { - return filterPostsByRule({ ...state.selected.item }, posts); + switch (state.selected.item.type) { + case CATEGORY_TYPE: + const rules = Object.values({ ...state.rules.items }); + return filterPostsByCategory({ ...state.selected.item }, rules, posts); + case RULE_TYPE: + return filterPostsByRule({ ...state.selected.item }, posts); } return []; diff --git a/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js b/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js index 7644fd7..36279ba 100644 --- a/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js +++ b/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js @@ -2,8 +2,10 @@ import React from 'react'; import { connect } from 'react-redux'; import { isEqual } from 'lodash'; +import { CATEGORY_TYPE } from '../../constants.js'; import { selectCategory, fetchCategory } from '../../actions/categories.js'; -import { fetchPostsByCategory } from '../../actions/posts.js'; +import { fetchPostsBySection } from '../../actions/posts.js'; +import { isSelected } from './functions.js'; import RuleItem from './RuleItem.js'; class CategoryItem extends React.Component { @@ -17,22 +19,19 @@ class CategoryItem extends React.Component { const category = this.props.category; this.props.selectCategory(category); - this.props.fetchPostsByCategory(category); - - if (category.unread === 0) { - this.props.fetchCategory(category); - } + this.props.fetchPostsBySection(category); + this.props.fetchCategory(category); } render() { const imageSrc = this.state.open ? '/static/chevron-down.svg' : '/static/chevron-right.svg'; - const selected = isEqual(this.props.category, this.props.selected); + const selected = isSelected(this.props.category, this.props.selected, CATEGORY_TYPE); const className = selected ? 'category category--selected' : 'category'; const ruleItems = this.props.rules.map(rule => { - return ; + return ; }); return ( @@ -58,7 +57,7 @@ class CategoryItem extends React.Component { const mapDispatchToProps = dispatch => ({ selectCategory: category => dispatch(selectCategory(category)), - fetchPostsByCategory: category => dispatch(fetchPostsByCategory(category)), + fetchPostsBySection: section => dispatch(fetchPostsBySection(section)), fetchCategory: category => dispatch(fetchCategory(category)), }); diff --git a/src/newsreader/js/pages/homepage/components/sidebar/RuleItem.js b/src/newsreader/js/pages/homepage/components/sidebar/RuleItem.js index 0a55aab..d93eb26 100644 --- a/src/newsreader/js/pages/homepage/components/sidebar/RuleItem.js +++ b/src/newsreader/js/pages/homepage/components/sidebar/RuleItem.js @@ -2,38 +2,33 @@ import React from 'react'; import { connect } from 'react-redux'; import { isEqual } from 'lodash'; +import { RULE_TYPE } from '../../constants.js'; import { selectRule, fetchRule } from '../../actions/rules.js'; -import { fetchPostsByRule } from '../../actions/posts.js'; +import { fetchPostsBySection } from '../../actions/posts.js'; +import { isSelected } from './functions.js'; class RuleItem extends React.Component { handleSelect() { const rule = { ...this.props.rule }; this.props.selectRule(rule); - this.props.fetchPostsByRule(rule); - - if (rule.unread === 0) { - this.props.fetchRule(rule); - } + this.props.fetchPostsBySection(rule); + this.props.fetchRule(rule); } render() { - const selected = isEqual(this.props.selected, this.props.rule); + const selected = isSelected(this.props.rule, this.props.selected, RULE_TYPE); const className = `rules__item ${selected ? 'rules__item--selected' : ''}`; + const favicon = this.props.rule.favicon + ? this.props.rule.favicon + : '/static/picture.svg'; return (
  • this.handleSelect()}>
    - {this.props.rule.favicon && ( - - - - )} + + +
    {this.props.rule.name}
    @@ -46,7 +41,7 @@ class RuleItem extends React.Component { const mapDispatchToProps = dispatch => ({ selectRule: rule => dispatch(selectRule(rule)), - fetchPostsByRule: rule => dispatch(fetchPostsByRule(rule)), + fetchPostsBySection: section => dispatch(fetchPostsBySection(section)), fetchRule: rule => dispatch(fetchRule(rule)), }); diff --git a/src/newsreader/js/pages/homepage/components/sidebar/Sidebar.js b/src/newsreader/js/pages/homepage/components/sidebar/Sidebar.js index cd73dbe..1721f03 100644 --- a/src/newsreader/js/pages/homepage/components/sidebar/Sidebar.js +++ b/src/newsreader/js/pages/homepage/components/sidebar/Sidebar.js @@ -22,7 +22,6 @@ class Sidebar extends React.Component { category={category} rules={rules} selected={this.props.selected.item} - selectedRule={this.props.selected.item} /> ); }); diff --git a/src/newsreader/js/pages/homepage/components/sidebar/functions.js b/src/newsreader/js/pages/homepage/components/sidebar/functions.js new file mode 100644 index 0000000..06ac716 --- /dev/null +++ b/src/newsreader/js/pages/homepage/components/sidebar/functions.js @@ -0,0 +1,7 @@ +export const isSelected = (section, selected, type) => { + if (!selected || selected.type != type) { + return false; + } + + return section.id === selected.id; +}; diff --git a/src/newsreader/js/pages/homepage/constants.js b/src/newsreader/js/pages/homepage/constants.js new file mode 100644 index 0000000..0e3f3d3 --- /dev/null +++ b/src/newsreader/js/pages/homepage/constants.js @@ -0,0 +1,2 @@ +export const RULE_TYPE = 'RULE'; +export const CATEGORY_TYPE = 'CATEGORY'; diff --git a/src/newsreader/js/pages/homepage/reducers/categories.js b/src/newsreader/js/pages/homepage/reducers/categories.js index f34930e..4fd391f 100644 --- a/src/newsreader/js/pages/homepage/reducers/categories.js +++ b/src/newsreader/js/pages/homepage/reducers/categories.js @@ -1,5 +1,7 @@ import { isEqual } from 'lodash'; +import { CATEGORY_TYPE, RULE_TYPE } from '../constants.js'; + import { RECEIVE_CATEGORY, RECEIVE_CATEGORIES, @@ -7,11 +9,36 @@ import { REQUEST_CATEGORIES, } from '../actions/categories.js'; -import { RECEIVE_RULE, RECEIVE_RULES } from '../actions/rules.js'; - import { MARK_POST_READ } from '../actions/posts.js'; import { MARK_SECTION_READ } from '../actions/selected.js'; +const markCategoryRead = (action, state) => { + const category = { ...state.items[action.section.id] }; + + return { + ...state, + items: { + ...state.items, + [category.id]: { ...category, unread: 0 }, + }, + }; +}; + +const markCategoryByRule = (action, state) => { + const category = { ...state.items[action.section.category] }; + + return { + ...state, + items: { + ...state.items, + [category.id]: { + ...category, + unread: category.unread - action.section.unread, + }, + }, + }; +}; + const defaultState = { items: {}, isFetching: false }; export const categories = (state = { ...defaultState }, action) => { @@ -21,7 +48,7 @@ export const categories = (state = { ...defaultState }, action) => { ...state, items: { ...state.items, - [action.category.id]: { ...action.category, rules: {} }, + [action.category.id]: { ...action.category }, }, isFetching: false, }; @@ -31,7 +58,6 @@ export const categories = (state = { ...defaultState }, action) => { Object.values({ ...action.categories }).forEach(category => { receivedCategories[category.id] = { ...category, - rules: {}, }; }); @@ -40,44 +66,6 @@ export const categories = (state = { ...defaultState }, action) => { items: { ...state.items, ...receivedCategories }, isFetching: false, }; - case RECEIVE_RULE: - const category = { ...state.items[action.rule.category] }; - - category['rules'][action.rule.id] = { ...action.rule }; - - return { - ...state, - items: { - ...state.items, - [category.id]: { ...category }, - }, - }; - case RECEIVE_RULES: - const relevantCategories = {}; - - Object.values({ ...action.rules }).forEach(rule => { - if (!(rule.category in relevantCategories)) { - const category = { ...state.items[rule.category] }; - - relevantCategories[rule.category] = { - ...category, - rules: { - ...category.rules, - [rule.id]: { ...rule }, - }, - }; - } else { - relevantCategories[rule.category]['rules'][rule.id] = { ...rule }; - } - }); - - return { - ...state, - items: { - ...state.items, - ...relevantCategories, - }, - }; case REQUEST_CATEGORIES: case REQUEST_CATEGORY: return { @@ -85,31 +73,24 @@ export const categories = (state = { ...defaultState }, action) => { isFetching: true, }; case MARK_POST_READ: - return { - ...state, - items: { ...state.items, [action.category.id]: { ...action.category } }, - }; - case MARK_SECTION_READ: - if (!isEqual(action.rule, {})) { - return { - ...state, - items: { - ...state.items, - [action.category.id]: { - ...action.category, - unread: action.category.unread - action.rule.unread, - }, - }, - }; - } + let category = { ...state.items[action.section.category] }; return { ...state, items: { ...state.items, - [action.category.id]: { ...action.category, unread: 0 }, + [category.id]: { ...category, unread: category.unread - 1 }, }, }; + case MARK_SECTION_READ: + switch (action.section.type) { + case CATEGORY_TYPE: + return markCategoryRead(action, state); + case RULE_TYPE: + return markCategoryByRule(action, state); + } + + return state; default: return state; } diff --git a/src/newsreader/js/pages/homepage/reducers/posts.js b/src/newsreader/js/pages/homepage/reducers/posts.js index 4f613ab..358b05b 100644 --- a/src/newsreader/js/pages/homepage/reducers/posts.js +++ b/src/newsreader/js/pages/homepage/reducers/posts.js @@ -1,4 +1,5 @@ import { isEqual } from 'lodash'; +import { CATEGORY_TYPE, RULE_TYPE } from '../constants.js'; import { SELECT_POST, @@ -39,14 +40,19 @@ export const posts = (state = { ...defaultState }, action) => { const updatedPosts = {}; let relatedPosts = []; - if (!isEqual(action.rule, {})) { - relatedPosts = Object.values({ ...state.items }).filter(post => { - return post.rule === action.rule.id; - }); - } else { - relatedPosts = Object.values({ ...state.items }).filter(post => { - return post.rule in { ...action.category.rules }; - }); + switch (action.section.type) { + case CATEGORY_TYPE: + relatedPosts = Object.values({ ...state.items }).filter(post => { + return post.rule in { ...action.section.rules }; + }); + + break; + case RULE_TYPE: + relatedPosts = Object.values({ ...state.items }).filter(post => { + return post.rule === action.section.id; + }); + + break; } relatedPosts.forEach(post => { diff --git a/src/newsreader/js/pages/homepage/reducers/rules.js b/src/newsreader/js/pages/homepage/reducers/rules.js index f23c98f..69cd703 100644 --- a/src/newsreader/js/pages/homepage/reducers/rules.js +++ b/src/newsreader/js/pages/homepage/reducers/rules.js @@ -1,5 +1,7 @@ import { isEqual } from 'lodash'; +import { CATEGORY_TYPE, RULE_TYPE } from '../constants.js'; + import { REQUEST_RULES, REQUEST_RULE, @@ -32,33 +34,47 @@ export const rules = (state = { ...defaultState }, action) => { isFetching: false, }; case MARK_POST_READ: + const rule = { ...state.items[action.section.id] }; + + return { + ...state, + items: { + ...state.items, + [rule.id]: { ...rule, unread: rule.unread - 1 }, + }, + }; case MARK_SECTION_READ: - if (!isEqual(action.rule, {})) { - return { - ...state, - items: { - ...state.items, - [action.rule.id]: { - ...action.rule, - unread: 0, + switch (action.section.type) { + case RULE_TYPE: + const rule = { ...state.items[action.section.id] }; + + return { + ...state, + items: { + ...state.items, + [rule.id]: { ...rule, unread: 0 }, }, - }, - }; + }; + case CATEGORY_TYPE: + const updatedRules = {}; + const categoryRules = Object.values({ ...state.items }).filter(rule => { + return rule.category === action.section.id; + }); + + categoryRules.forEach(rule => { + updatedRules[rule.id] = { ...rule, unread: 0 }; + }); + + return { + ...state, + items: { + ...state.items, + ...updatedRules, + }, + }; } - const updatedRules = {}; - Object.values({ ...state.items }).forEach(rule => { - if (rule.category === action.category.id) { - updatedRules[rule.id] = { - ...rule, - unread: 0, - }; - } else { - updatedRules[rule.id] = { ...rule }; - } - }); - - return { ...state, items: { ...updatedRules } }; + return state; default: return state; } diff --git a/src/newsreader/js/pages/homepage/reducers/selected.js b/src/newsreader/js/pages/homepage/reducers/selected.js index dea13db..0bef90c 100644 --- a/src/newsreader/js/pages/homepage/reducers/selected.js +++ b/src/newsreader/js/pages/homepage/reducers/selected.js @@ -17,9 +17,32 @@ export const selected = (state = { ...defaultState }, action) => { switch (action.type) { case SELECT_CATEGORY: case SELECT_RULE: + if (state.item) { + if ( + state.item.id === action.section.id && + state.item.type === action.section.type + ) { + if (state.item.clicks >= 2) { + return { + ...state, + item: { ...action.section, clicks: 0 }, + next: false, + lastReached: false, + }; + } + + return { + ...state, + item: { ...action.section, clicks: state.item.clicks + 1 }, + next: false, + lastReached: false, + }; + } + } + return { ...state, - item: action.item, + item: { ...action.section, clicks: 0 }, next: false, lastReached: false, }; @@ -53,16 +76,9 @@ export const selected = (state = { ...defaultState }, action) => { post: {}, }; case MARK_SECTION_READ: - if (!isEqual(action.rule, {})) { - return { - ...state, - item: { ...action.rule, unread: 0 }, - }; - } - return { ...state, - item: { ...action.category }, + item: { ...action.section, clicks: 0, unread: 0 }, }; default: return state; diff --git a/src/newsreader/news/core/templates/core/category.html b/src/newsreader/news/core/templates/core/category.html index afc5dee..1b0203c 100644 --- a/src/newsreader/news/core/templates/core/category.html +++ b/src/newsreader/news/core/templates/core/category.html @@ -34,7 +34,7 @@
  • {% block rule-input %}{% endblock %} + src="{% if rule.favicon %}{{ rule.favicon }}{% else %}/static/picture.svg{% endif %}" /> {{ rule.name }}
  • {% endfor %} diff --git a/src/newsreader/scss/components/body/_body.scss b/src/newsreader/scss/components/body/_body.scss index d5ede84..d4316d3 100644 --- a/src/newsreader/scss/components/body/_body.scss +++ b/src/newsreader/scss/components/body/_body.scss @@ -4,9 +4,11 @@ background-color: $gainsboro; font-family: $default-font; + color: $default-font-color; & * { margin: 0; padding: 0; + box-sizing: border-box; } } diff --git a/src/newsreader/scss/elements/a/_a.scss b/src/newsreader/scss/elements/a/_a.scss new file mode 100644 index 0000000..69113c5 --- /dev/null +++ b/src/newsreader/scss/elements/a/_a.scss @@ -0,0 +1,3 @@ +a { + @extend .link; +} diff --git a/src/newsreader/scss/elements/a/index.scss b/src/newsreader/scss/elements/a/index.scss new file mode 100644 index 0000000..1bf17d2 --- /dev/null +++ b/src/newsreader/scss/elements/a/index.scss @@ -0,0 +1 @@ +@import "a"; diff --git a/src/newsreader/scss/elements/button/_button.scss b/src/newsreader/scss/elements/button/_button.scss index 215029f..a6bec19 100644 --- a/src/newsreader/scss/elements/button/_button.scss +++ b/src/newsreader/scss/elements/button/_button.scss @@ -17,7 +17,7 @@ } &--success, &--confirm { - color: $white; + color: $white !important; background-color: $confirm-green; &:hover { @@ -26,7 +26,7 @@ } &--error, &--cancel { - color: $white; + color: $white !important; background-color: $error-red; &:hover { @@ -36,7 +36,7 @@ } &--primary { - color: $white; + color: $white !important; background-color: darken($azureish-white, +20%); &:hover { diff --git a/src/newsreader/scss/elements/index.scss b/src/newsreader/scss/elements/index.scss index 8cf91e9..cc587d8 100644 --- a/src/newsreader/scss/elements/index.scss +++ b/src/newsreader/scss/elements/index.scss @@ -1,5 +1,6 @@ @import "./button/index"; @import "link/index"; +@import "a/index"; @import "h1/index"; @import "h2/index"; @import "h3/index"; diff --git a/src/newsreader/scss/elements/link/_link.scss b/src/newsreader/scss/elements/link/_link.scss index 56d39c2..96f737f 100644 --- a/src/newsreader/scss/elements/link/_link.scss +++ b/src/newsreader/scss/elements/link/_link.scss @@ -1,4 +1,5 @@ .link { + color: darken($azureish-white, 30%); text-decoration: none; &:hover { diff --git a/src/newsreader/scss/pages/homepage/components/category/_category.scss b/src/newsreader/scss/pages/homepage/components/category/_category.scss index b272bb1..9d8451f 100644 --- a/src/newsreader/scss/pages/homepage/components/category/_category.scss +++ b/src/newsreader/scss/pages/homepage/components/category/_category.scss @@ -36,11 +36,10 @@ } &:hover { - background-color: darken($gainsboro, 10%); + background-color: darken($azureish-white, +10%); } &--selected { - color: $white; - background-color: darken($gainsboro, 10%); + background-color: darken($azureish-white, +10%); } } diff --git a/src/newsreader/scss/pages/homepage/components/post-block/_post-block.scss b/src/newsreader/scss/pages/homepage/components/post-block/_post-block.scss index e0d2dcd..ee30eb4 100644 --- a/src/newsreader/scss/pages/homepage/components/post-block/_post-block.scss +++ b/src/newsreader/scss/pages/homepage/components/post-block/_post-block.scss @@ -2,7 +2,7 @@ display: flex; flex-direction: column; - width: 60%; + width: 70%; margin: 0 0 2% 0; font-family: $article-font; diff --git a/src/newsreader/scss/pages/homepage/components/post/_post.scss b/src/newsreader/scss/pages/homepage/components/post/_post.scss index a0ddae4..51ac564 100644 --- a/src/newsreader/scss/pages/homepage/components/post/_post.scss +++ b/src/newsreader/scss/pages/homepage/components/post/_post.scss @@ -54,6 +54,7 @@ line-height: 1.5; font-family: $article-font; + font-size: 18px; & p { padding: 20px 0 0 0; @@ -80,7 +81,7 @@ margin: 0 0 0 5px; & img { - width: 10px; + width: 20px; } } @@ -99,7 +100,7 @@ width: 10%; font-family: $button-font; - color: $nickel; + color: lighten($default-font-color, +10%); & h5 { margin: 10px 0 0 0; @@ -119,7 +120,7 @@ } & h5:last-child { - background-color: $light-green; + background-color: $beige; } } } diff --git a/src/newsreader/scss/pages/homepage/components/posts-header/_posts-header.scss b/src/newsreader/scss/pages/homepage/components/posts-header/_posts-header.scss index 068c2b3..c0ea036 100644 --- a/src/newsreader/scss/pages/homepage/components/posts-header/_posts-header.scss +++ b/src/newsreader/scss/pages/homepage/components/posts-header/_posts-header.scss @@ -1,10 +1,12 @@ .posts-header { display: flex; + align-items: center; padding: 0 5px 0 0; width: 80%; &__link { + display: flex; padding: 0 0 0 5px; } @@ -12,10 +14,10 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: small; + font-size: 16px; &--read { - color: $gainsboro; + color: darken($gainsboro, +10%); } } } diff --git a/src/newsreader/scss/pages/homepage/components/posts/_posts.scss b/src/newsreader/scss/pages/homepage/components/posts/_posts.scss index 0734e77..9329a85 100644 --- a/src/newsreader/scss/pages/homepage/components/posts/_posts.scss +++ b/src/newsreader/scss/pages/homepage/components/posts/_posts.scss @@ -7,20 +7,19 @@ &__item { display: flex; justify-content: space-between; + align-items: center; - padding: 10px 0 0px 0; + padding: 10px 0 10px 0; border-radius: 2px; border-bottom: 2px solid $azureish-white; &:hover { cursor: pointer; - background-color: lighten($gainsboro, 10%); + background-color: $gainsboro; } & span { - width: 20%; - font-size: small; text-overflow: ellipsis; overflow: hidden; diff --git a/src/newsreader/scss/pages/homepage/components/rule/_rule.scss b/src/newsreader/scss/pages/homepage/components/rule/_rule.scss index dba7124..ba34bf6 100644 --- a/src/newsreader/scss/pages/homepage/components/rule/_rule.scss +++ b/src/newsreader/scss/pages/homepage/components/rule/_rule.scss @@ -1,5 +1,6 @@ .rule { display: flex; + align-items: center; width: 80%; &__title { @@ -7,4 +8,8 @@ text-overflow: ellipsis; white-space: nowrap; } + + & span { + display: flex; + } } diff --git a/src/newsreader/scss/pages/homepage/components/rules/_rules.scss b/src/newsreader/scss/pages/homepage/components/rules/_rules.scss index c8b261e..ca12c9c 100644 --- a/src/newsreader/scss/pages/homepage/components/rules/_rules.scss +++ b/src/newsreader/scss/pages/homepage/components/rules/_rules.scss @@ -18,12 +18,11 @@ &:hover { cursor: pointer; - background-color: darken($gainsboro, 10%); + background-color: darken($azureish-white, +10%); } &--selected { - color: $white; - background-color: darken($gainsboro, 10%); + background-color: darken($azureish-white, +10%); } } } diff --git a/src/newsreader/scss/pages/homepage/elements/badge/_badge.scss b/src/newsreader/scss/pages/homepage/elements/badge/_badge.scss index 6abab65..1e2db24 100644 --- a/src/newsreader/scss/pages/homepage/elements/badge/_badge.scss +++ b/src/newsreader/scss/pages/homepage/elements/badge/_badge.scss @@ -3,12 +3,11 @@ padding-left: 8px; padding-right: 8px; - border-radius: 20%; + border-radius: 2px; text-align: center; - background-color: darken($pink, 10%); - color: $white; + background-color: lighten($pewter-blue, +20%); font-family: $button-font; font-size: small; diff --git a/src/newsreader/scss/partials/_colors.scss b/src/newsreader/scss/partials/_colors.scss index 641afff..0259883 100644 --- a/src/newsreader/scss/partials/_colors.scss +++ b/src/newsreader/scss/partials/_colors.scss @@ -10,7 +10,7 @@ $azureish-white: rgba(205, 230, 245, 1); $pewter-blue: rgba(141, 167, 190, 1); // light gray -$gainsboro: rgba(224, 221, 220, 1); +$gainsboro: rgba(238, 238, 238, 1); // medium gray $roman-silver: rgba(135, 145, 158, 1); @@ -35,3 +35,4 @@ $cancel-red: $error-red; $border-gray: rgba(227, 227, 227, 1); $focus-blue: darken($azureish-white, +50%); +$default-font-color: rgba(48, 51, 53, 1); diff --git a/src/newsreader/static/icons/alarm.svg b/src/newsreader/static/icons/alarm.svg new file mode 100755 index 0000000..94c3105 --- /dev/null +++ b/src/newsreader/static/icons/alarm.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/apartment.svg b/src/newsreader/static/icons/apartment.svg new file mode 100755 index 0000000..ff14506 --- /dev/null +++ b/src/newsreader/static/icons/apartment.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/newsreader/static/icons/arrow-down-circle.svg b/src/newsreader/static/icons/arrow-down-circle.svg new file mode 100755 index 0000000..c09ee4e --- /dev/null +++ b/src/newsreader/static/icons/arrow-down-circle.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/arrow-down.svg b/src/newsreader/static/icons/arrow-down.svg new file mode 100755 index 0000000..d802122 --- /dev/null +++ b/src/newsreader/static/icons/arrow-down.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/arrow-left-circle.svg b/src/newsreader/static/icons/arrow-left-circle.svg new file mode 100755 index 0000000..a135160 --- /dev/null +++ b/src/newsreader/static/icons/arrow-left-circle.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/arrow-left.svg b/src/newsreader/static/icons/arrow-left.svg index 88526e8..c1168f6 100644 --- a/src/newsreader/static/icons/arrow-left.svg +++ b/src/newsreader/static/icons/arrow-left.svg @@ -1,6 +1,6 @@ - - - - - - \ No newline at end of file + + + + + + diff --git a/src/newsreader/static/icons/arrow-right-circle.svg b/src/newsreader/static/icons/arrow-right-circle.svg new file mode 100755 index 0000000..9b8d2f5 --- /dev/null +++ b/src/newsreader/static/icons/arrow-right-circle.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/arrow-right.svg b/src/newsreader/static/icons/arrow-right.svg new file mode 100755 index 0000000..21c0a0c --- /dev/null +++ b/src/newsreader/static/icons/arrow-right.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/arrow-up-circle.svg b/src/newsreader/static/icons/arrow-up-circle.svg new file mode 100755 index 0000000..044fc48 --- /dev/null +++ b/src/newsreader/static/icons/arrow-up-circle.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/arrow-up.svg b/src/newsreader/static/icons/arrow-up.svg new file mode 100755 index 0000000..fa2d12e --- /dev/null +++ b/src/newsreader/static/icons/arrow-up.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/bicycle.svg b/src/newsreader/static/icons/bicycle.svg new file mode 100755 index 0000000..2599f0d --- /dev/null +++ b/src/newsreader/static/icons/bicycle.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/newsreader/static/icons/bold.svg b/src/newsreader/static/icons/bold.svg new file mode 100755 index 0000000..86271f6 --- /dev/null +++ b/src/newsreader/static/icons/bold.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/newsreader/static/icons/book.svg b/src/newsreader/static/icons/book.svg new file mode 100755 index 0000000..cc4892e --- /dev/null +++ b/src/newsreader/static/icons/book.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/bookmark.svg b/src/newsreader/static/icons/bookmark.svg new file mode 100755 index 0000000..6057646 --- /dev/null +++ b/src/newsreader/static/icons/bookmark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/briefcase.svg b/src/newsreader/static/icons/briefcase.svg new file mode 100755 index 0000000..58d54b6 --- /dev/null +++ b/src/newsreader/static/icons/briefcase.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/bubble.svg b/src/newsreader/static/icons/bubble.svg new file mode 100755 index 0000000..87317cc --- /dev/null +++ b/src/newsreader/static/icons/bubble.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/bug.svg b/src/newsreader/static/icons/bug.svg new file mode 100755 index 0000000..7cedf5a --- /dev/null +++ b/src/newsreader/static/icons/bug.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/bullhorn.svg b/src/newsreader/static/icons/bullhorn.svg new file mode 100755 index 0000000..bc8ffcc --- /dev/null +++ b/src/newsreader/static/icons/bullhorn.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/bus.svg b/src/newsreader/static/icons/bus.svg new file mode 100755 index 0000000..1a3416f --- /dev/null +++ b/src/newsreader/static/icons/bus.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/newsreader/static/icons/calendar-full.svg b/src/newsreader/static/icons/calendar-full.svg new file mode 100755 index 0000000..c835eaa --- /dev/null +++ b/src/newsreader/static/icons/calendar-full.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/newsreader/static/icons/camera-video.svg b/src/newsreader/static/icons/camera-video.svg new file mode 100755 index 0000000..99e6ebe --- /dev/null +++ b/src/newsreader/static/icons/camera-video.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/camera.svg b/src/newsreader/static/icons/camera.svg new file mode 100755 index 0000000..b1e662a --- /dev/null +++ b/src/newsreader/static/icons/camera.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/car.svg b/src/newsreader/static/icons/car.svg new file mode 100755 index 0000000..dd9af01 --- /dev/null +++ b/src/newsreader/static/icons/car.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/newsreader/static/icons/cart.svg b/src/newsreader/static/icons/cart.svg new file mode 100755 index 0000000..cf0df35 --- /dev/null +++ b/src/newsreader/static/icons/cart.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/newsreader/static/icons/chart-bars.svg b/src/newsreader/static/icons/chart-bars.svg new file mode 100755 index 0000000..15b0f54 --- /dev/null +++ b/src/newsreader/static/icons/chart-bars.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/newsreader/static/icons/checkmark-circle.svg b/src/newsreader/static/icons/checkmark-circle.svg new file mode 100755 index 0000000..e46b93f --- /dev/null +++ b/src/newsreader/static/icons/checkmark-circle.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/chevron-down-circle.svg b/src/newsreader/static/icons/chevron-down-circle.svg new file mode 100755 index 0000000..e530413 --- /dev/null +++ b/src/newsreader/static/icons/chevron-down-circle.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/chevron-down.svg b/src/newsreader/static/icons/chevron-down.svg index 211fd17..5342155 100644 --- a/src/newsreader/static/icons/chevron-down.svg +++ b/src/newsreader/static/icons/chevron-down.svg @@ -1,6 +1,6 @@ - - - - - - \ No newline at end of file + + + + + + diff --git a/src/newsreader/static/icons/chevron-left-circle.svg b/src/newsreader/static/icons/chevron-left-circle.svg new file mode 100755 index 0000000..495f43b --- /dev/null +++ b/src/newsreader/static/icons/chevron-left-circle.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/chevron-left.svg b/src/newsreader/static/icons/chevron-left.svg new file mode 100755 index 0000000..4117261 --- /dev/null +++ b/src/newsreader/static/icons/chevron-left.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/chevron-right-circle.svg b/src/newsreader/static/icons/chevron-right-circle.svg new file mode 100755 index 0000000..b5bd4d4 --- /dev/null +++ b/src/newsreader/static/icons/chevron-right-circle.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/chevron-right.svg b/src/newsreader/static/icons/chevron-right.svg index ea2b601..14db8e8 100644 --- a/src/newsreader/static/icons/chevron-right.svg +++ b/src/newsreader/static/icons/chevron-right.svg @@ -1,6 +1,6 @@ - - - - - - \ No newline at end of file + + + + + + diff --git a/src/newsreader/static/icons/chevron-up-circle.svg b/src/newsreader/static/icons/chevron-up-circle.svg new file mode 100755 index 0000000..9e8acdd --- /dev/null +++ b/src/newsreader/static/icons/chevron-up-circle.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/chevron-up.svg b/src/newsreader/static/icons/chevron-up.svg new file mode 100755 index 0000000..778bbaa --- /dev/null +++ b/src/newsreader/static/icons/chevron-up.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/circle-minus.svg b/src/newsreader/static/icons/circle-minus.svg new file mode 100755 index 0000000..1e4a8c9 --- /dev/null +++ b/src/newsreader/static/icons/circle-minus.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/clock.svg b/src/newsreader/static/icons/clock.svg new file mode 100755 index 0000000..55169fe --- /dev/null +++ b/src/newsreader/static/icons/clock.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/cloud-check.svg b/src/newsreader/static/icons/cloud-check.svg new file mode 100755 index 0000000..253b386 --- /dev/null +++ b/src/newsreader/static/icons/cloud-check.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/cloud-download.svg b/src/newsreader/static/icons/cloud-download.svg new file mode 100755 index 0000000..1cbe14c --- /dev/null +++ b/src/newsreader/static/icons/cloud-download.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/cloud-sync.svg b/src/newsreader/static/icons/cloud-sync.svg new file mode 100755 index 0000000..c239e0d --- /dev/null +++ b/src/newsreader/static/icons/cloud-sync.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/newsreader/static/icons/cloud-upload.svg b/src/newsreader/static/icons/cloud-upload.svg new file mode 100755 index 0000000..9fcbabe --- /dev/null +++ b/src/newsreader/static/icons/cloud-upload.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/cloud.svg b/src/newsreader/static/icons/cloud.svg new file mode 100755 index 0000000..24918b1 --- /dev/null +++ b/src/newsreader/static/icons/cloud.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/code.svg b/src/newsreader/static/icons/code.svg new file mode 100755 index 0000000..fbdfd39 --- /dev/null +++ b/src/newsreader/static/icons/code.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/newsreader/static/icons/coffee-cup.svg b/src/newsreader/static/icons/coffee-cup.svg new file mode 100755 index 0000000..478ee27 --- /dev/null +++ b/src/newsreader/static/icons/coffee-cup.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/cog.svg b/src/newsreader/static/icons/cog.svg new file mode 100755 index 0000000..4b4948a --- /dev/null +++ b/src/newsreader/static/icons/cog.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/construction.svg b/src/newsreader/static/icons/construction.svg new file mode 100755 index 0000000..72726ff --- /dev/null +++ b/src/newsreader/static/icons/construction.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/crop.svg b/src/newsreader/static/icons/crop.svg new file mode 100755 index 0000000..1568611 --- /dev/null +++ b/src/newsreader/static/icons/crop.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/newsreader/static/icons/cross-circle.svg b/src/newsreader/static/icons/cross-circle.svg new file mode 100755 index 0000000..dbc08af --- /dev/null +++ b/src/newsreader/static/icons/cross-circle.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/cross.svg b/src/newsreader/static/icons/cross.svg new file mode 100755 index 0000000..2edad61 --- /dev/null +++ b/src/newsreader/static/icons/cross.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/database.svg b/src/newsreader/static/icons/database.svg new file mode 100755 index 0000000..64236ad --- /dev/null +++ b/src/newsreader/static/icons/database.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/diamond.svg b/src/newsreader/static/icons/diamond.svg new file mode 100755 index 0000000..679df4a --- /dev/null +++ b/src/newsreader/static/icons/diamond.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/dice.svg b/src/newsreader/static/icons/dice.svg new file mode 100755 index 0000000..6859d8b --- /dev/null +++ b/src/newsreader/static/icons/dice.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/newsreader/static/icons/dinner.svg b/src/newsreader/static/icons/dinner.svg new file mode 100755 index 0000000..0cf54d6 --- /dev/null +++ b/src/newsreader/static/icons/dinner.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/direction-ltr.svg b/src/newsreader/static/icons/direction-ltr.svg new file mode 100755 index 0000000..827ada0 --- /dev/null +++ b/src/newsreader/static/icons/direction-ltr.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/direction-rtl.svg b/src/newsreader/static/icons/direction-rtl.svg new file mode 100755 index 0000000..47ce7d4 --- /dev/null +++ b/src/newsreader/static/icons/direction-rtl.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/download.svg b/src/newsreader/static/icons/download.svg new file mode 100755 index 0000000..51b561f --- /dev/null +++ b/src/newsreader/static/icons/download.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/drop.svg b/src/newsreader/static/icons/drop.svg new file mode 100755 index 0000000..d726f67 --- /dev/null +++ b/src/newsreader/static/icons/drop.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/earth.svg b/src/newsreader/static/icons/earth.svg new file mode 100755 index 0000000..411b22c --- /dev/null +++ b/src/newsreader/static/icons/earth.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/enter-down.svg b/src/newsreader/static/icons/enter-down.svg new file mode 100755 index 0000000..85794a1 --- /dev/null +++ b/src/newsreader/static/icons/enter-down.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/enter.svg b/src/newsreader/static/icons/enter.svg new file mode 100755 index 0000000..1130e28 --- /dev/null +++ b/src/newsreader/static/icons/enter.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/envelope.svg b/src/newsreader/static/icons/envelope.svg new file mode 100755 index 0000000..f030873 --- /dev/null +++ b/src/newsreader/static/icons/envelope.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/exit-up.svg b/src/newsreader/static/icons/exit-up.svg new file mode 100755 index 0000000..c67b28c --- /dev/null +++ b/src/newsreader/static/icons/exit-up.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/exit.svg b/src/newsreader/static/icons/exit.svg new file mode 100755 index 0000000..b54f4b5 --- /dev/null +++ b/src/newsreader/static/icons/exit.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/eye.svg b/src/newsreader/static/icons/eye.svg new file mode 100755 index 0000000..f67edba --- /dev/null +++ b/src/newsreader/static/icons/eye.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/file-add.svg b/src/newsreader/static/icons/file-add.svg new file mode 100755 index 0000000..5f0b06d --- /dev/null +++ b/src/newsreader/static/icons/file-add.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/file-empty.svg b/src/newsreader/static/icons/file-empty.svg new file mode 100755 index 0000000..c203b20 --- /dev/null +++ b/src/newsreader/static/icons/file-empty.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/film-play.svg b/src/newsreader/static/icons/film-play.svg new file mode 100755 index 0000000..d2a9db0 --- /dev/null +++ b/src/newsreader/static/icons/film-play.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/flag.svg b/src/newsreader/static/icons/flag.svg new file mode 100755 index 0000000..2f91d68 --- /dev/null +++ b/src/newsreader/static/icons/flag.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/frame-contract.svg b/src/newsreader/static/icons/frame-contract.svg new file mode 100755 index 0000000..3a70458 --- /dev/null +++ b/src/newsreader/static/icons/frame-contract.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/newsreader/static/icons/frame-expand.svg b/src/newsreader/static/icons/frame-expand.svg new file mode 100755 index 0000000..40f6af0 --- /dev/null +++ b/src/newsreader/static/icons/frame-expand.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/newsreader/static/icons/funnel.svg b/src/newsreader/static/icons/funnel.svg new file mode 100755 index 0000000..d2688f7 --- /dev/null +++ b/src/newsreader/static/icons/funnel.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/gift.svg b/src/newsreader/static/icons/gift.svg new file mode 100755 index 0000000..72c0bdc --- /dev/null +++ b/src/newsreader/static/icons/gift.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/graduation-hat.svg b/src/newsreader/static/icons/graduation-hat.svg new file mode 100755 index 0000000..abb6099 --- /dev/null +++ b/src/newsreader/static/icons/graduation-hat.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/hand.svg b/src/newsreader/static/icons/hand.svg new file mode 100755 index 0000000..1eacb25 --- /dev/null +++ b/src/newsreader/static/icons/hand.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/heart-pulse.svg b/src/newsreader/static/icons/heart-pulse.svg new file mode 100755 index 0000000..62e0c08 --- /dev/null +++ b/src/newsreader/static/icons/heart-pulse.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/newsreader/static/icons/heart.svg b/src/newsreader/static/icons/heart.svg new file mode 100755 index 0000000..660600b --- /dev/null +++ b/src/newsreader/static/icons/heart.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/highlight.svg b/src/newsreader/static/icons/highlight.svg new file mode 100755 index 0000000..eb706fa --- /dev/null +++ b/src/newsreader/static/icons/highlight.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/history.svg b/src/newsreader/static/icons/history.svg new file mode 100755 index 0000000..4acfb22 --- /dev/null +++ b/src/newsreader/static/icons/history.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/home.svg b/src/newsreader/static/icons/home.svg new file mode 100755 index 0000000..c259dc3 --- /dev/null +++ b/src/newsreader/static/icons/home.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/hourglass.svg b/src/newsreader/static/icons/hourglass.svg new file mode 100755 index 0000000..0e72fba --- /dev/null +++ b/src/newsreader/static/icons/hourglass.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/inbox.svg b/src/newsreader/static/icons/inbox.svg new file mode 100755 index 0000000..2e0a9f8 --- /dev/null +++ b/src/newsreader/static/icons/inbox.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/indent-decrease.svg b/src/newsreader/static/icons/indent-decrease.svg new file mode 100755 index 0000000..9443dcf --- /dev/null +++ b/src/newsreader/static/icons/indent-decrease.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/newsreader/static/icons/indent-increase.svg b/src/newsreader/static/icons/indent-increase.svg new file mode 100755 index 0000000..25666f4 --- /dev/null +++ b/src/newsreader/static/icons/indent-increase.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/newsreader/static/icons/italic.svg b/src/newsreader/static/icons/italic.svg new file mode 100755 index 0000000..6ddde14 --- /dev/null +++ b/src/newsreader/static/icons/italic.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/keyboard.svg b/src/newsreader/static/icons/keyboard.svg new file mode 100755 index 0000000..ae51d9c --- /dev/null +++ b/src/newsreader/static/icons/keyboard.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/newsreader/static/icons/laptop-phone.svg b/src/newsreader/static/icons/laptop-phone.svg new file mode 100755 index 0000000..e67b5b3 --- /dev/null +++ b/src/newsreader/static/icons/laptop-phone.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/newsreader/static/icons/laptop.svg b/src/newsreader/static/icons/laptop.svg new file mode 100755 index 0000000..51fdb07 --- /dev/null +++ b/src/newsreader/static/icons/laptop.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/layers.svg b/src/newsreader/static/icons/layers.svg new file mode 100755 index 0000000..8cec392 --- /dev/null +++ b/src/newsreader/static/icons/layers.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/newsreader/static/icons/leaf.svg b/src/newsreader/static/icons/leaf.svg new file mode 100755 index 0000000..428a264 --- /dev/null +++ b/src/newsreader/static/icons/leaf.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/license.svg b/src/newsreader/static/icons/license.svg new file mode 100755 index 0000000..cf95671 --- /dev/null +++ b/src/newsreader/static/icons/license.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/newsreader/static/icons/lighter.svg b/src/newsreader/static/icons/lighter.svg new file mode 100755 index 0000000..09c5e0c --- /dev/null +++ b/src/newsreader/static/icons/lighter.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/line-spacing.svg b/src/newsreader/static/icons/line-spacing.svg new file mode 100755 index 0000000..8cf8f24 --- /dev/null +++ b/src/newsreader/static/icons/line-spacing.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/newsreader/static/icons/linearicons.svg b/src/newsreader/static/icons/linearicons.svg new file mode 100755 index 0000000..bbf2a26 --- /dev/null +++ b/src/newsreader/static/icons/linearicons.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/link.svg b/src/newsreader/static/icons/link.svg index 57caa9f..7bb4a0e 100644 --- a/src/newsreader/static/icons/link.svg +++ b/src/newsreader/static/icons/link.svg @@ -1 +1,7 @@ - \ No newline at end of file + + + + + + + diff --git a/src/newsreader/static/icons/list.svg b/src/newsreader/static/icons/list.svg new file mode 100755 index 0000000..6255ad9 --- /dev/null +++ b/src/newsreader/static/icons/list.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/newsreader/static/icons/location.svg b/src/newsreader/static/icons/location.svg new file mode 100755 index 0000000..272c1d9 --- /dev/null +++ b/src/newsreader/static/icons/location.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/lock.svg b/src/newsreader/static/icons/lock.svg new file mode 100755 index 0000000..76259cf --- /dev/null +++ b/src/newsreader/static/icons/lock.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/magic-wand.svg b/src/newsreader/static/icons/magic-wand.svg new file mode 100755 index 0000000..55753d8 --- /dev/null +++ b/src/newsreader/static/icons/magic-wand.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/newsreader/static/icons/magnifier.svg b/src/newsreader/static/icons/magnifier.svg new file mode 100755 index 0000000..9c26539 --- /dev/null +++ b/src/newsreader/static/icons/magnifier.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/map-marker.svg b/src/newsreader/static/icons/map-marker.svg new file mode 100755 index 0000000..3a20637 --- /dev/null +++ b/src/newsreader/static/icons/map-marker.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/map.svg b/src/newsreader/static/icons/map.svg new file mode 100755 index 0000000..319e300 --- /dev/null +++ b/src/newsreader/static/icons/map.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/menu-circle.svg b/src/newsreader/static/icons/menu-circle.svg new file mode 100755 index 0000000..f3544eb --- /dev/null +++ b/src/newsreader/static/icons/menu-circle.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/newsreader/static/icons/menu.svg b/src/newsreader/static/icons/menu.svg new file mode 100755 index 0000000..e0952e0 --- /dev/null +++ b/src/newsreader/static/icons/menu.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/newsreader/static/icons/mic.svg b/src/newsreader/static/icons/mic.svg new file mode 100755 index 0000000..9c08d2d --- /dev/null +++ b/src/newsreader/static/icons/mic.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/moon.svg b/src/newsreader/static/icons/moon.svg new file mode 100755 index 0000000..3f150c6 --- /dev/null +++ b/src/newsreader/static/icons/moon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/move.svg b/src/newsreader/static/icons/move.svg new file mode 100755 index 0000000..86c223d --- /dev/null +++ b/src/newsreader/static/icons/move.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/music-note.svg b/src/newsreader/static/icons/music-note.svg new file mode 100755 index 0000000..a4ab001 --- /dev/null +++ b/src/newsreader/static/icons/music-note.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/mustache.svg b/src/newsreader/static/icons/mustache.svg new file mode 100755 index 0000000..8c12f70 --- /dev/null +++ b/src/newsreader/static/icons/mustache.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/newsreader/static/icons/neutral.svg b/src/newsreader/static/icons/neutral.svg new file mode 100755 index 0000000..4f55a69 --- /dev/null +++ b/src/newsreader/static/icons/neutral.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/newsreader/static/icons/page-break.svg b/src/newsreader/static/icons/page-break.svg new file mode 100755 index 0000000..493c248 --- /dev/null +++ b/src/newsreader/static/icons/page-break.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/newsreader/static/icons/paperclip.svg b/src/newsreader/static/icons/paperclip.svg new file mode 100755 index 0000000..2afa342 --- /dev/null +++ b/src/newsreader/static/icons/paperclip.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/paw.svg b/src/newsreader/static/icons/paw.svg new file mode 100755 index 0000000..4170890 --- /dev/null +++ b/src/newsreader/static/icons/paw.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/newsreader/static/icons/pencil.svg b/src/newsreader/static/icons/pencil.svg new file mode 100755 index 0000000..fb618b1 --- /dev/null +++ b/src/newsreader/static/icons/pencil.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/phone-handset.svg b/src/newsreader/static/icons/phone-handset.svg new file mode 100755 index 0000000..cdadbe5 --- /dev/null +++ b/src/newsreader/static/icons/phone-handset.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/phone.svg b/src/newsreader/static/icons/phone.svg new file mode 100755 index 0000000..2883bc8 --- /dev/null +++ b/src/newsreader/static/icons/phone.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/newsreader/static/icons/picture.svg b/src/newsreader/static/icons/picture.svg new file mode 100755 index 0000000..c927a37 --- /dev/null +++ b/src/newsreader/static/icons/picture.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/newsreader/static/icons/pie-chart.svg b/src/newsreader/static/icons/pie-chart.svg new file mode 100755 index 0000000..a02c1a5 --- /dev/null +++ b/src/newsreader/static/icons/pie-chart.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/pilcrow.svg b/src/newsreader/static/icons/pilcrow.svg new file mode 100755 index 0000000..1a61c76 --- /dev/null +++ b/src/newsreader/static/icons/pilcrow.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/plus-circle.svg b/src/newsreader/static/icons/plus-circle.svg new file mode 100755 index 0000000..a0cf6fa --- /dev/null +++ b/src/newsreader/static/icons/plus-circle.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/pointer-down.svg b/src/newsreader/static/icons/pointer-down.svg new file mode 100755 index 0000000..e2321c6 --- /dev/null +++ b/src/newsreader/static/icons/pointer-down.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/pointer-left.svg b/src/newsreader/static/icons/pointer-left.svg new file mode 100755 index 0000000..e3aa356 --- /dev/null +++ b/src/newsreader/static/icons/pointer-left.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/pointer-right.svg b/src/newsreader/static/icons/pointer-right.svg new file mode 100755 index 0000000..011ffba --- /dev/null +++ b/src/newsreader/static/icons/pointer-right.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/pointer-up.svg b/src/newsreader/static/icons/pointer-up.svg new file mode 100755 index 0000000..08f82a1 --- /dev/null +++ b/src/newsreader/static/icons/pointer-up.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/poop.svg b/src/newsreader/static/icons/poop.svg new file mode 100755 index 0000000..570fade --- /dev/null +++ b/src/newsreader/static/icons/poop.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/power-switch.svg b/src/newsreader/static/icons/power-switch.svg new file mode 100755 index 0000000..9f47372 --- /dev/null +++ b/src/newsreader/static/icons/power-switch.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/printer.svg b/src/newsreader/static/icons/printer.svg new file mode 100755 index 0000000..d338626 --- /dev/null +++ b/src/newsreader/static/icons/printer.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/newsreader/static/icons/pushpin.svg b/src/newsreader/static/icons/pushpin.svg new file mode 100755 index 0000000..d88009d --- /dev/null +++ b/src/newsreader/static/icons/pushpin.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/question-circle.svg b/src/newsreader/static/icons/question-circle.svg new file mode 100755 index 0000000..45e5929 --- /dev/null +++ b/src/newsreader/static/icons/question-circle.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/newsreader/static/icons/redo.svg b/src/newsreader/static/icons/redo.svg new file mode 100755 index 0000000..ec68693 --- /dev/null +++ b/src/newsreader/static/icons/redo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/rocket.svg b/src/newsreader/static/icons/rocket.svg new file mode 100755 index 0000000..552cbcc --- /dev/null +++ b/src/newsreader/static/icons/rocket.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/newsreader/static/icons/sad.svg b/src/newsreader/static/icons/sad.svg new file mode 100755 index 0000000..ed63b85 --- /dev/null +++ b/src/newsreader/static/icons/sad.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/newsreader/static/icons/screen.svg b/src/newsreader/static/icons/screen.svg new file mode 100755 index 0000000..057f0d9 --- /dev/null +++ b/src/newsreader/static/icons/screen.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/select.svg b/src/newsreader/static/icons/select.svg new file mode 100755 index 0000000..3e8cfee --- /dev/null +++ b/src/newsreader/static/icons/select.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/shirt.svg b/src/newsreader/static/icons/shirt.svg new file mode 100755 index 0000000..f6dd52d --- /dev/null +++ b/src/newsreader/static/icons/shirt.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/smartphone.svg b/src/newsreader/static/icons/smartphone.svg new file mode 100755 index 0000000..779f7d2 --- /dev/null +++ b/src/newsreader/static/icons/smartphone.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/newsreader/static/icons/smile.svg b/src/newsreader/static/icons/smile.svg new file mode 100755 index 0000000..a1a0a0a --- /dev/null +++ b/src/newsreader/static/icons/smile.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/newsreader/static/icons/sort-alpha-asc.svg b/src/newsreader/static/icons/sort-alpha-asc.svg new file mode 100755 index 0000000..56b8d3f --- /dev/null +++ b/src/newsreader/static/icons/sort-alpha-asc.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/newsreader/static/icons/sort-amount-asc.svg b/src/newsreader/static/icons/sort-amount-asc.svg new file mode 100755 index 0000000..d24c0e4 --- /dev/null +++ b/src/newsreader/static/icons/sort-amount-asc.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/newsreader/static/icons/spell-check.svg b/src/newsreader/static/icons/spell-check.svg new file mode 100755 index 0000000..1c4875c --- /dev/null +++ b/src/newsreader/static/icons/spell-check.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/newsreader/static/icons/star-empty.svg b/src/newsreader/static/icons/star-empty.svg new file mode 100755 index 0000000..fd5098c --- /dev/null +++ b/src/newsreader/static/icons/star-empty.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/newsreader/static/icons/star-half.svg b/src/newsreader/static/icons/star-half.svg new file mode 100755 index 0000000..c48aa79 --- /dev/null +++ b/src/newsreader/static/icons/star-half.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/newsreader/static/icons/star.svg b/src/newsreader/static/icons/star.svg new file mode 100755 index 0000000..3302123 --- /dev/null +++ b/src/newsreader/static/icons/star.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/store.svg b/src/newsreader/static/icons/store.svg new file mode 100755 index 0000000..9fce882 --- /dev/null +++ b/src/newsreader/static/icons/store.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/newsreader/static/icons/strikethrough.svg b/src/newsreader/static/icons/strikethrough.svg new file mode 100755 index 0000000..825d1d0 --- /dev/null +++ b/src/newsreader/static/icons/strikethrough.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/newsreader/static/icons/sun.svg b/src/newsreader/static/icons/sun.svg new file mode 100755 index 0000000..b9d9038 --- /dev/null +++ b/src/newsreader/static/icons/sun.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/newsreader/static/icons/sync.svg b/src/newsreader/static/icons/sync.svg new file mode 100755 index 0000000..982223f --- /dev/null +++ b/src/newsreader/static/icons/sync.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/tablet.svg b/src/newsreader/static/icons/tablet.svg new file mode 100755 index 0000000..8554d69 --- /dev/null +++ b/src/newsreader/static/icons/tablet.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/newsreader/static/icons/tag.svg b/src/newsreader/static/icons/tag.svg new file mode 100755 index 0000000..f2a207b --- /dev/null +++ b/src/newsreader/static/icons/tag.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/text-align-center.svg b/src/newsreader/static/icons/text-align-center.svg new file mode 100755 index 0000000..4ca60a9 --- /dev/null +++ b/src/newsreader/static/icons/text-align-center.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/newsreader/static/icons/text-align-justify.svg b/src/newsreader/static/icons/text-align-justify.svg new file mode 100755 index 0000000..814e51e --- /dev/null +++ b/src/newsreader/static/icons/text-align-justify.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/newsreader/static/icons/text-align-left.svg b/src/newsreader/static/icons/text-align-left.svg new file mode 100755 index 0000000..ee71585 --- /dev/null +++ b/src/newsreader/static/icons/text-align-left.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/newsreader/static/icons/text-align-right.svg b/src/newsreader/static/icons/text-align-right.svg new file mode 100755 index 0000000..4884054 --- /dev/null +++ b/src/newsreader/static/icons/text-align-right.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/newsreader/static/icons/text-format-remove.svg b/src/newsreader/static/icons/text-format-remove.svg new file mode 100755 index 0000000..f472c8c --- /dev/null +++ b/src/newsreader/static/icons/text-format-remove.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/newsreader/static/icons/text-format.svg b/src/newsreader/static/icons/text-format.svg new file mode 100755 index 0000000..5fe551c --- /dev/null +++ b/src/newsreader/static/icons/text-format.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/text-size.svg b/src/newsreader/static/icons/text-size.svg new file mode 100755 index 0000000..aef49f1 --- /dev/null +++ b/src/newsreader/static/icons/text-size.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/thumbs-down.svg b/src/newsreader/static/icons/thumbs-down.svg new file mode 100755 index 0000000..efe684e --- /dev/null +++ b/src/newsreader/static/icons/thumbs-down.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/thumbs-up.svg b/src/newsreader/static/icons/thumbs-up.svg new file mode 100755 index 0000000..bcfaec2 --- /dev/null +++ b/src/newsreader/static/icons/thumbs-up.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/train.svg b/src/newsreader/static/icons/train.svg new file mode 100755 index 0000000..efdeef9 --- /dev/null +++ b/src/newsreader/static/icons/train.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/newsreader/static/icons/trash.svg b/src/newsreader/static/icons/trash.svg new file mode 100755 index 0000000..4b35080 --- /dev/null +++ b/src/newsreader/static/icons/trash.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/newsreader/static/icons/underline.svg b/src/newsreader/static/icons/underline.svg new file mode 100755 index 0000000..0d440e5 --- /dev/null +++ b/src/newsreader/static/icons/underline.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/undo.svg b/src/newsreader/static/icons/undo.svg new file mode 100755 index 0000000..ba812e0 --- /dev/null +++ b/src/newsreader/static/icons/undo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/unlink.svg b/src/newsreader/static/icons/unlink.svg new file mode 100755 index 0000000..2c176da --- /dev/null +++ b/src/newsreader/static/icons/unlink.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/newsreader/static/icons/upload.svg b/src/newsreader/static/icons/upload.svg new file mode 100755 index 0000000..12ac621 --- /dev/null +++ b/src/newsreader/static/icons/upload.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/user.svg b/src/newsreader/static/icons/user.svg new file mode 100755 index 0000000..5931e5f --- /dev/null +++ b/src/newsreader/static/icons/user.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/users.svg b/src/newsreader/static/icons/users.svg new file mode 100755 index 0000000..743ef13 --- /dev/null +++ b/src/newsreader/static/icons/users.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/newsreader/static/icons/volume-high.svg b/src/newsreader/static/icons/volume-high.svg new file mode 100755 index 0000000..7e891c2 --- /dev/null +++ b/src/newsreader/static/icons/volume-high.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/newsreader/static/icons/volume-low.svg b/src/newsreader/static/icons/volume-low.svg new file mode 100755 index 0000000..4872d97 --- /dev/null +++ b/src/newsreader/static/icons/volume-low.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/newsreader/static/icons/volume-medium.svg b/src/newsreader/static/icons/volume-medium.svg new file mode 100755 index 0000000..71b2fca --- /dev/null +++ b/src/newsreader/static/icons/volume-medium.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/newsreader/static/icons/volume.svg b/src/newsreader/static/icons/volume.svg new file mode 100755 index 0000000..edb1f6e --- /dev/null +++ b/src/newsreader/static/icons/volume.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/newsreader/static/icons/warning.svg b/src/newsreader/static/icons/warning.svg new file mode 100755 index 0000000..21f9e68 --- /dev/null +++ b/src/newsreader/static/icons/warning.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/newsreader/static/icons/wheelchair.svg b/src/newsreader/static/icons/wheelchair.svg new file mode 100755 index 0000000..25cdac0 --- /dev/null +++ b/src/newsreader/static/icons/wheelchair.svg @@ -0,0 +1,7 @@ + + + + + + + From b2829716b0d5add3112bf6ace2aa5a8f963de747 Mon Sep 17 00:00:00 2001 From: sonny Date: Wed, 27 Nov 2019 22:10:02 +0100 Subject: [PATCH 027/422] Account management --- gulp/sass.js | 3 + requirements/base.txt | 1 + .../accounts/templates/accounts/login.html | 8 +- src/newsreader/accounts/tests/factories.py | 20 +++ .../accounts/tests/test_activation.py | 102 +++++++++++ .../accounts/tests/test_password_reset.py | 164 ++++++++++++++++++ .../accounts/tests/test_registration.py | 110 ++++++++++++ .../accounts/tests/test_resend_activation.py | 80 +++++++++ src/newsreader/accounts/urls.py | 47 ++++- src/newsreader/accounts/views.py | 85 ++++++++- src/newsreader/conf/base.py | 5 + .../core/templates/core/category-create.html | 2 +- .../core/templates/core/category-update.html | 2 +- .../scss/components/body/_body.scss | 5 + .../scss/components/fieldset/_fieldset.scss | 11 ++ .../scss/components/fieldset/index.scss | 1 + .../scss/components/form/_form.scss | 14 +- src/newsreader/scss/components/index.scss | 1 + .../scss/components/list/_list.scss | 4 + src/newsreader/scss/elements/a/_a.scss | 3 - src/newsreader/scss/elements/a/index.scss | 1 - .../scss/elements/help-text/_help-text.scss | 6 +- src/newsreader/scss/elements/index.scss | 3 +- .../scss/elements/input/_input.scss | 5 + .../scss/elements/label/_label.scss | 4 + src/newsreader/scss/elements/link/_link.scss | 4 + .../scss/elements/small/_small.scss | 5 + .../scss/pages/activate/components/index.scss | 0 .../scss/pages/activate/elements/index.scss | 0 src/newsreader/scss/pages/activate/index.scss | 8 + .../password-reset/components/index.scss | 2 + .../_password-reset-confirm-form.scss | 3 + .../password-reset-confirm-form/index.scss | 1 + .../_password-reset-form.scss | 18 ++ .../components/password-reset-form/index.scss | 1 + .../pages/password-reset/elements/index.scss | 0 .../scss/pages/password-reset/index.scss | 8 + .../activation-form/_activation-form.scss | 11 ++ .../components/activation-form/index.scss | 1 + .../scss/pages/register/components/index.scss | 2 + .../register-form/_register-form.scss | 11 ++ .../components/register-form/index.scss | 1 + .../scss/pages/register/elements/index.scss | 0 src/newsreader/scss/pages/register/index.scss | 8 + src/newsreader/templates/base.html | 1 + .../password_reset_complete.html | 28 +++ .../password_reset_confirm.html | 59 +++++++ .../password-reset/password_reset_done.html | 27 +++ .../password-reset/password_reset_email.html | 30 ++++ .../password-reset/password_reset_form.html | 34 ++++ .../password-reset/password_reset_subject.txt | 6 + .../registration/activation_complete.html | 35 ++++ .../registration/activation_email.html | 72 ++++++++ .../registration/activation_email.txt | 52 ++++++ .../registration/activation_email_subject.txt | 28 +++ .../registration/activation_failure.html | 31 ++++ .../activation_resend_complete.html | 35 ++++ .../registration/activation_resend_form.html | 39 +++++ .../registration/registration_closed.html | 25 +++ .../registration/registration_complete.html | 33 ++++ .../registration/registration_form.html | 26 +++ 61 files changed, 1311 insertions(+), 21 deletions(-) create mode 100644 src/newsreader/accounts/tests/test_activation.py create mode 100644 src/newsreader/accounts/tests/test_password_reset.py create mode 100644 src/newsreader/accounts/tests/test_registration.py create mode 100644 src/newsreader/accounts/tests/test_resend_activation.py create mode 100644 src/newsreader/scss/components/fieldset/_fieldset.scss create mode 100644 src/newsreader/scss/components/fieldset/index.scss delete mode 100644 src/newsreader/scss/elements/a/_a.scss delete mode 100644 src/newsreader/scss/elements/a/index.scss create mode 100644 src/newsreader/scss/pages/activate/components/index.scss create mode 100644 src/newsreader/scss/pages/activate/elements/index.scss create mode 100644 src/newsreader/scss/pages/activate/index.scss create mode 100644 src/newsreader/scss/pages/password-reset/components/index.scss create mode 100644 src/newsreader/scss/pages/password-reset/components/password-reset-confirm-form/_password-reset-confirm-form.scss create mode 100644 src/newsreader/scss/pages/password-reset/components/password-reset-confirm-form/index.scss create mode 100644 src/newsreader/scss/pages/password-reset/components/password-reset-form/_password-reset-form.scss create mode 100644 src/newsreader/scss/pages/password-reset/components/password-reset-form/index.scss create mode 100644 src/newsreader/scss/pages/password-reset/elements/index.scss create mode 100644 src/newsreader/scss/pages/password-reset/index.scss create mode 100644 src/newsreader/scss/pages/register/components/activation-form/_activation-form.scss create mode 100644 src/newsreader/scss/pages/register/components/activation-form/index.scss create mode 100644 src/newsreader/scss/pages/register/components/index.scss create mode 100644 src/newsreader/scss/pages/register/components/register-form/_register-form.scss create mode 100644 src/newsreader/scss/pages/register/components/register-form/index.scss create mode 100644 src/newsreader/scss/pages/register/elements/index.scss create mode 100644 src/newsreader/scss/pages/register/index.scss create mode 100755 src/newsreader/templates/password-reset/password_reset_complete.html create mode 100755 src/newsreader/templates/password-reset/password_reset_confirm.html create mode 100755 src/newsreader/templates/password-reset/password_reset_done.html create mode 100755 src/newsreader/templates/password-reset/password_reset_email.html create mode 100755 src/newsreader/templates/password-reset/password_reset_form.html create mode 100644 src/newsreader/templates/password-reset/password_reset_subject.txt create mode 100755 src/newsreader/templates/registration/activation_complete.html create mode 100644 src/newsreader/templates/registration/activation_email.html create mode 100644 src/newsreader/templates/registration/activation_email.txt create mode 100644 src/newsreader/templates/registration/activation_email_subject.txt create mode 100644 src/newsreader/templates/registration/activation_failure.html create mode 100644 src/newsreader/templates/registration/activation_resend_complete.html create mode 100644 src/newsreader/templates/registration/activation_resend_form.html create mode 100755 src/newsreader/templates/registration/registration_closed.html create mode 100755 src/newsreader/templates/registration/registration_complete.html create mode 100644 src/newsreader/templates/registration/registration_form.html diff --git a/gulp/sass.js b/gulp/sass.js index abe9c53..2a71455 100644 --- a/gulp/sass.js +++ b/gulp/sass.js @@ -13,6 +13,9 @@ export const CORE_DIR = path.join(PROJECT_DIR, 'news', 'core', 'static', 'core') const taskMappings = [ { name: 'login', destDir: `${ACCOUNTS_DIR}/${STATIC_SUFFIX}` }, + { name: 'register', destDir: `${ACCOUNTS_DIR}/${STATIC_SUFFIX}` }, + { name: 'activate', destDir: `${ACCOUNTS_DIR}/${STATIC_SUFFIX}` }, + { name: 'password-reset', destDir: `${ACCOUNTS_DIR}/${STATIC_SUFFIX}` }, { name: 'homepage', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` }, { name: 'categories', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` }, { name: 'category', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` }, diff --git a/requirements/base.txt b/requirements/base.txt index b3bcaf7..b5a6858 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -7,6 +7,7 @@ Django==2.2 django-celery-beat==1.5.0 djangorestframework==3.9.4 django-rest-swagger==2.2.0 +django-registration-redux==2.6 lxml==4.3.4 feedparser==5.2.1 idna==2.8 diff --git a/src/newsreader/accounts/templates/accounts/login.html b/src/newsreader/accounts/templates/accounts/login.html index f923643..f98a216 100644 --- a/src/newsreader/accounts/templates/accounts/login.html +++ b/src/newsreader/accounts/templates/accounts/login.html @@ -10,14 +10,18 @@
    diff --git a/src/newsreader/accounts/tests/factories.py b/src/newsreader/accounts/tests/factories.py index d073c1c..fc13d74 100644 --- a/src/newsreader/accounts/tests/factories.py +++ b/src/newsreader/accounts/tests/factories.py @@ -1,8 +1,20 @@ +import hashlib +import string + +from django.utils.crypto import get_random_string + import factory +from registration.models import RegistrationProfile + from newsreader.accounts.models import User +def get_activation_key(): + random_string = get_random_string(length=32, allowed_chars=string.printable) + return hashlib.sha1(random_string.encode("utf-8")).hexdigest() + + class UserFactory(factory.django.DjangoModelFactory): email = factory.Faker("email") password = factory.Faker("password") @@ -17,3 +29,11 @@ class UserFactory(factory.django.DjangoModelFactory): class Meta: model = User + + +class RegistrationProfileFactory(factory.django.DjangoModelFactory): + user = factory.SubFactory(UserFactory) + activation_key = factory.LazyFunction(get_activation_key) + + class Meta: + model = RegistrationProfile diff --git a/src/newsreader/accounts/tests/test_activation.py b/src/newsreader/accounts/tests/test_activation.py new file mode 100644 index 0000000..b5a9943 --- /dev/null +++ b/src/newsreader/accounts/tests/test_activation.py @@ -0,0 +1,102 @@ +import datetime + +from django.conf import settings +from django.core import mail +from django.test import TestCase +from django.test.utils import override_settings +from django.urls import reverse +from django.utils.translation import gettext as _ + +from registration.models import RegistrationProfile + +from newsreader.accounts.models import User +from newsreader.accounts.tests.factories import UserFactory + + +class ActivationTestCase(TestCase): + def setUp(self): + self.register_url = reverse("accounts:register") + self.register_success_url = reverse("accounts:register-complete") + self.success_url = reverse("accounts:activate-complete") + + def test_activation(self): + data = { + "email": "test@test.com", + "password1": "test12456", + "password2": "test12456", + } + + response = self.client.post(self.register_url, data) + self.assertRedirects(response, self.register_success_url) + + register_profile = RegistrationProfile.objects.get() + + kwargs = {"activation_key": register_profile.activation_key} + response = self.client.get(reverse("accounts:activate", kwargs=kwargs)) + + self.assertRedirects(response, self.success_url) + + def test_expired_key(self): + data = { + "email": "test@test.com", + "password1": "test12456", + "password2": "test12456", + } + + response = self.client.post(self.register_url, data) + + register_profile = RegistrationProfile.objects.get() + user = register_profile.user + + user.date_joined -= datetime.timedelta(days=settings.ACCOUNT_ACTIVATION_DAYS) + user.save() + + kwargs = {"activation_key": register_profile.activation_key} + response = self.client.get(reverse("accounts:activate", kwargs=kwargs)) + + self.assertEqual(200, response.status_code) + self.assertContains(response, _("Account activation failed")) + + user.refresh_from_db() + self.assertFalse(user.is_active) + + def test_invalid_key(self): + data = { + "email": "test@test.com", + "password1": "test12456", + "password2": "test12456", + } + + response = self.client.post(self.register_url, data) + self.assertRedirects(response, self.register_success_url) + + kwargs = {"activation_key": "not-a-valid-key"} + response = self.client.get(reverse("accounts:activate", kwargs=kwargs)) + + self.assertContains(response, _("Account activation failed")) + + user = User.objects.get() + + self.assertEquals(user.is_active, False) + + def test_activated_key(self): + data = { + "email": "test@test.com", + "password1": "test12456", + "password2": "test12456", + } + + response = self.client.post(self.register_url, data) + self.assertRedirects(response, self.register_success_url) + + register_profile = RegistrationProfile.objects.get() + + kwargs = {"activation_key": register_profile.activation_key} + response = self.client.get(reverse("accounts:activate", kwargs=kwargs)) + + self.assertRedirects(response, self.success_url) + + # try this a second time + response = self.client.get(reverse("accounts:activate", kwargs=kwargs)) + + self.assertRedirects(response, self.success_url) diff --git a/src/newsreader/accounts/tests/test_password_reset.py b/src/newsreader/accounts/tests/test_password_reset.py new file mode 100644 index 0000000..1f818c8 --- /dev/null +++ b/src/newsreader/accounts/tests/test_password_reset.py @@ -0,0 +1,164 @@ +from typing import Dict + +from django.contrib.auth.tokens import default_token_generator as token_generator +from django.core import mail +from django.test import TestCase +from django.urls import reverse +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode +from django.utils.translation import gettext as _ + +from newsreader.accounts.models import User +from newsreader.accounts.tests.factories import UserFactory + + +class PasswordResetTestCase(TestCase): + def setUp(self): + self.url = reverse("accounts:password-reset") + self.success_url = reverse("accounts:password-reset-done") + self.user = UserFactory(email="test@test.com") + + def test_simple(self): + response = self.client.get(self.url) + + self.assertEquals(response.status_code, 200) + + def test_password_change(self): + data = {"email": "test@test.com"} + + response = self.client.post(self.url, data) + self.assertRedirects(response, self.success_url) + + self.assertEquals(len(mail.outbox), 1) + + def test_unkown_email(self): + data = {"email": "unknown@test.com"} + + response = self.client.post(self.url, data) + self.assertRedirects(response, self.success_url) + + self.assertEquals(len(mail.outbox), 0) + + def test_repeatedly(self): + data = {"email": "test@test.com"} + + response = self.client.post(self.url, data) + self.assertRedirects(response, self.success_url) + + response = self.client.post(self.url, data) + self.assertRedirects(response, self.success_url) + + self.assertEquals(len(mail.outbox), 2) + + +class PasswordResetConfirmTestCase(TestCase): + def setUp(self): + self.success_url = reverse("accounts:password-reset-complete") + self.user = UserFactory(email="test@test.com") + + def _get_reset_credentials(self) -> Dict: + data = {"email": self.user.email} + + response = self.client.post(reverse("accounts:password-reset"), data) + + return { + "uidb64": response.context[0]["uid"], + "token": response.context[0]["token"], + } + + def test_simple(self): + kwargs = self._get_reset_credentials() + + response = self.client.get( + reverse("accounts:password-reset-confirm", kwargs=kwargs) + ) + + self.assertRedirects( + response, f"/accounts/password-reset/{kwargs['uidb64']}/set-password/" + ) + + def test_confirm_password(self): + kwargs = self._get_reset_credentials() + + response = self.client.get( + reverse("accounts:password-reset-confirm", kwargs=kwargs) + ) + + data = {"new_password1": "jabbadabadoe", "new_password2": "jabbadabadoe"} + + response = self.client.post(response.url, data) + + self.assertRedirects(response, self.success_url) + + self.user.refresh_from_db() + + self.assertTrue(self.user.check_password("jabbadabadoe")) + + def test_wrong_uuid(self): + correct_kwargs = self._get_reset_credentials() + wrong_kwargs = {"uidb64": "burp", "token": correct_kwargs["token"]} + + response = self.client.get( + reverse("accounts:password-reset-confirm", kwargs=wrong_kwargs) + ) + + self.assertContains(response, _("Password reset unsuccessful")) + + def test_wrong_token(self): + correct_kwargs = self._get_reset_credentials() + wrong_kwargs = {"uidb64": correct_kwargs["uidb64"], "token": "token"} + + response = self.client.get( + reverse("accounts:password-reset-confirm", kwargs=wrong_kwargs) + ) + + self.assertContains(response, _("Password reset unsuccessful")) + + def test_wrong_url_args(self): + kwargs = {"uidb64": "burp", "token": "token"} + + response = self.client.get( + reverse("accounts:password-reset-confirm", kwargs=kwargs) + ) + + self.assertContains(response, _("Password reset unsuccessful")) + + def test_token_repeatedly(self): + kwargs = self._get_reset_credentials() + + response = self.client.get( + reverse("accounts:password-reset-confirm", kwargs=kwargs) + ) + + data = {"new_password1": "jabbadabadoe", "new_password2": "jabbadabadoe"} + + self.client.post(response.url, data) + + response = self.client.get( + reverse("accounts:password-reset-confirm", kwargs=kwargs) + ) + + self.assertContains(response, _("Password reset unsuccessful")) + + def test_change_form_repeatedly(self): + kwargs = self._get_reset_credentials() + + response = self.client.get( + reverse("accounts:password-reset-confirm", kwargs=kwargs) + ) + + data = {"new_password1": "new-password", "new_password2": "new-password"} + + self.client.post(response.url, data) + + data = {"new_password1": "jabbadabadoe", "new_password2": "jabbadabadoe"} + + response = self.client.post( + reverse("accounts:password-reset-confirm", kwargs=kwargs) + ) + + self.assertContains(response, _("Password reset unsuccessful")) + + self.user.refresh_from_db() + + self.assertTrue(self.user.check_password("new-password")) diff --git a/src/newsreader/accounts/tests/test_registration.py b/src/newsreader/accounts/tests/test_registration.py new file mode 100644 index 0000000..27c87bf --- /dev/null +++ b/src/newsreader/accounts/tests/test_registration.py @@ -0,0 +1,110 @@ +from django.core import mail +from django.test import TransactionTestCase as TestCase +from django.test.utils import override_settings +from django.urls import reverse +from django.utils.translation import gettext as _ + +from registration.models import RegistrationProfile + +from newsreader.accounts.models import User +from newsreader.accounts.tests.factories import UserFactory + + +class RegistrationTestCase(TestCase): + def setUp(self): + self.url = reverse("accounts:register") + self.success_url = reverse("accounts:register-complete") + self.disallowed_url = reverse("accounts:register-closed") + + def test_simple(self): + response = self.client.get(self.url) + + self.assertEquals(response.status_code, 200) + + def test_registration(self): + data = { + "email": "test@test.com", + "password1": "test12456", + "password2": "test12456", + } + + response = self.client.post(self.url, data) + self.assertRedirects(response, self.success_url) + + self.assertEquals(User.objects.count(), 1) + self.assertEquals(RegistrationProfile.objects.count(), 1) + + user = User.objects.get() + + self.assertEquals(user.is_active, False) + self.assertEquals(len(mail.outbox), 1) + + def test_existing_email(self): + UserFactory(email="test@test.com") + + data = { + "email": "test@test.com", + "password1": "test12456", + "password2": "test12456", + } + + response = self.client.post(self.url, data) + self.assertEquals(response.status_code, 200) + + self.assertEquals(User.objects.count(), 1) + self.assertContains(response, _("User with this Email address already exists")) + + def test_pending_registration(self): + data = { + "email": "test@test.com", + "password1": "test12456", + "password2": "test12456", + } + + response = self.client.post(self.url, data) + self.assertRedirects(response, self.success_url) + + self.assertEquals(User.objects.count(), 1) + self.assertEquals(RegistrationProfile.objects.count(), 1) + + user = User.objects.get() + + self.assertEquals(user.is_active, False) + self.assertEquals(len(mail.outbox), 1) + + response = self.client.post(self.url, data) + self.assertEquals(response.status_code, 200) + self.assertContains(response, _("User with this Email address already exists")) + + def test_disabled_account(self): + UserFactory(email="test@test.com", is_active=False) + + data = { + "email": "test@test.com", + "password1": "test12456", + "password2": "test12456", + } + + response = self.client.post(self.url, data) + self.assertEquals(response.status_code, 200) + + self.assertEquals(User.objects.count(), 1) + self.assertContains(response, _("User with this Email address already exists")) + + @override_settings(REGISTRATION_OPEN=False) + def test_registration_closed(self): + response = self.client.get(self.url) + + self.assertRedirects(response, self.disallowed_url) + + data = { + "email": "test@test.com", + "password1": "test12456", + "password2": "test12456", + } + + response = self.client.post(self.url, data) + self.assertRedirects(response, self.disallowed_url) + + self.assertEquals(User.objects.count(), 0) + self.assertEquals(RegistrationProfile.objects.count(), 0) diff --git a/src/newsreader/accounts/tests/test_resend_activation.py b/src/newsreader/accounts/tests/test_resend_activation.py new file mode 100644 index 0000000..a18df2a --- /dev/null +++ b/src/newsreader/accounts/tests/test_resend_activation.py @@ -0,0 +1,80 @@ +from django.conf import settings +from django.core import mail +from django.test import TransactionTestCase as TestCase +from django.test.utils import override_settings +from django.urls import reverse +from django.utils.translation import gettext as _ + +from registration.models import RegistrationProfile + +from newsreader.accounts.models import User +from newsreader.accounts.tests.factories import RegistrationProfileFactory, UserFactory + + +class ResendActivationTestCase(TestCase): + def setUp(self): + self.url = reverse("accounts:activate-resend") + self.success_url = reverse("accounts:activate-complete") + self.register_url = reverse("accounts:register") + + def test_simple(self): + response = self.client.get(self.url) + + self.assertEquals(response.status_code, 200) + + def test_resent_form(self): + data = { + "email": "test@test.com", + "password1": "test12456", + "password2": "test12456", + } + + response = self.client.post(self.register_url, data) + + register_profile = RegistrationProfile.objects.get() + original_kwargs = {"activation_key": register_profile.activation_key} + + response = self.client.post(self.url, {"email": "test@test.com"}) + + self.assertContains(response, _("We have sent an email to")) + + self.assertEquals(len(mail.outbox), 2) + + register_profile.refresh_from_db() + + kwargs = {"activation_key": register_profile.activation_key} + response = self.client.get(reverse("accounts:activate", kwargs=kwargs)) + + self.assertRedirects(response, self.success_url) + + register_profile.refresh_from_db() + user = register_profile.user + + self.assertEquals(user.is_active, True) + + # test the old activation code + response = self.client.get(reverse("accounts:activate", kwargs=original_kwargs)) + + self.assertEquals(response.status_code, 200) + self.assertContains(response, _("Account activation failed")) + + def test_existing_account(self): + user = UserFactory(is_active=True) + profile = RegistrationProfileFactory(user=user, activated=True) + + response = self.client.post(self.url, {"email": user.email}) + self.assertEquals(response.status_code, 200) + + # default behaviour is to show success page but not send an email + self.assertContains(response, _("We have sent an email to")) + + self.assertEquals(len(mail.outbox), 0) + + def test_no_account(self): + response = self.client.post(self.url, {"email": "fake@mail.com"}) + self.assertEquals(response.status_code, 200) + + # default behaviour is to show success page but not send an email + self.assertContains(response, _("We have sent an email to")) + + self.assertEquals(len(mail.outbox), 0) diff --git a/src/newsreader/accounts/urls.py b/src/newsreader/accounts/urls.py index 61593ed..ac8b2ab 100644 --- a/src/newsreader/accounts/urls.py +++ b/src/newsreader/accounts/urls.py @@ -1,9 +1,54 @@ from django.urls import include, path -from newsreader.accounts.views import LoginView, LogoutView +from newsreader.accounts.views import ( + ActivationCompleteView, + ActivationResendView, + ActivationView, + LoginView, + LogoutView, + PasswordResetCompleteView, + PasswordResetConfirmView, + PasswordResetDoneView, + PasswordResetView, + RegistrationClosedView, + RegistrationCompleteView, + RegistrationView, +) urlpatterns = [ path("login/", LoginView.as_view(), name="login"), path("logout/", LogoutView.as_view(), name="logout"), + path("register/", RegistrationView.as_view(), name="register"), + path( + "register/complete/", RegistrationCompleteView.as_view(), name="register-complete" + ), + path("register/closed/", RegistrationClosedView.as_view(), name="register-closed"), + path( + "activate/complete/", ActivationCompleteView.as_view(), name="activate-complete" + ), + path("activate/resend/", ActivationResendView.as_view(), name="activate-resend"), + path( + # This URL should be placed after all activate/ url's (see arg) + "activate//", + ActivationView.as_view(), + name="activate", + ), + path("password-reset/", PasswordResetView.as_view(), name="password-reset"), + path( + "password-reset/done/", + PasswordResetDoneView.as_view(), + name="password-reset-done", + ), + path( + "password-reset///", + PasswordResetConfirmView.as_view(), + name="password-reset-confirm", + ), + path( + "password-reset/done/", + PasswordResetCompleteView.as_view(), + name="password-reset-complete", + ), + # TODO: create password change views ] diff --git a/src/newsreader/accounts/views.py b/src/newsreader/accounts/views.py index 4957350..28ae92d 100644 --- a/src/newsreader/accounts/views.py +++ b/src/newsreader/accounts/views.py @@ -1,14 +1,91 @@ -from django.contrib.auth.views import LoginView as DjangoLoginView -from django.contrib.auth.views import LogoutView as DjangoLogoutView +from django.contrib.auth import views as django_views +from django.shortcuts import render from django.urls import reverse_lazy +from django.views.generic import TemplateView + +from registration.backends.default import views as registration_views -class LoginView(DjangoLoginView): +class LoginView(django_views.LoginView): template_name = "accounts/login.html" def get_success_url(self): return reverse_lazy("index") -class LogoutView(DjangoLogoutView): +class LogoutView(django_views.LogoutView): next_page = reverse_lazy("accounts:login") + + +# RegistrationView shows a registration form and sends the email +# RegistrationCompleteView shows after filling in the registration form +# ActivationView is send within the activation email and activates the account +# ActivationCompleteView shows the success screen when activation was succesful +# ActivationResendView can be used when activation links are expired +# RegistrationClosedView shows when registration is disabled +class RegistrationView(registration_views.RegistrationView): + disallowed_url = reverse_lazy("accounts:register-closed") + template_name = "registration/registration_form.html" + success_url = reverse_lazy("accounts:register-complete") + + +class RegistrationCompleteView(TemplateView): + template_name = "registration/registration_complete.html" + + +class RegistrationClosedView(TemplateView): + template_name = "registration/registration_closed.html" + + +# Redirects or renders failed activation template +class ActivationView(registration_views.ActivationView): + template_name = "registration/activation_failure.html" + + def get_success_url(self, user): + return ("accounts:activate-complete", (), {}) + + +class ActivationCompleteView(TemplateView): + template_name = "registration/activation_complete.html" + + +# Renders activation form resend or resend_activation_complete +class ActivationResendView(registration_views.ResendActivationView): + template_name = "registration/activation_resend_form.html" + + def render_form_submitted_template(self, form): + """ + Renders resend activation complete template with the submitted email. + + """ + email = form.cleaned_data["email"] + context = {"email": email} + + return render( + self.request, "registration/activation_resend_complete.html", context + ) + + +# PasswordResetView sends the mail +# PasswordResetDoneView shows a success message for the above +# PasswordResetConfirmView checks the link the user clicked and +# prompts for a new password +# PasswordResetCompleteView shows a success message for the above +class PasswordResetView(django_views.PasswordResetView): + template_name = "password-reset/password_reset_form.html" + subject_template_name = "password-reset/password_reset_subject.txt" + email_template_name = "password-reset/password_reset_email.html" + success_url = reverse_lazy("accounts:password-reset-done") + + +class PasswordResetDoneView(django_views.PasswordResetDoneView): + template_name = "password-reset/password_reset_done.html" + + +class PasswordResetConfirmView(django_views.PasswordResetConfirmView): + template_name = "password-reset/password_reset_confirm.html" + success_url = reverse_lazy("accounts:password-reset-complete") + + +class PasswordResetCompleteView(django_views.PasswordResetCompleteView): + template_name = "password-reset/password_reset_complete.html" diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 46bc34a..6f19f91 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -41,6 +41,7 @@ INSTALLED_APPS = [ "rest_framework_swagger", "celery", "django_celery_beat", + "registration", # app modules "newsreader.accounts", "newsreader.news.core", @@ -137,3 +138,7 @@ SWAGGER_SETTINGS = { "LOGOUT_URL": "rest_framework:logout", "DOC_EXPANSION": "list", } + +REGISTRATION_OPEN = True +ACCOUNT_ACTIVATION_DAYS = 7 +REGISTRATION_AUTO_LOGIN = True diff --git a/src/newsreader/news/core/templates/core/category-create.html b/src/newsreader/news/core/templates/core/category-create.html index 49801bc..50c3a0a 100644 --- a/src/newsreader/news/core/templates/core/category-create.html +++ b/src/newsreader/news/core/templates/core/category-create.html @@ -1,7 +1,7 @@ {% extends "core/category.html" %} {% block form-header %} -

    Create a category

    +

    Create a category

    {% endblock %} {% block name-input %} diff --git a/src/newsreader/news/core/templates/core/category-update.html b/src/newsreader/news/core/templates/core/category-update.html index 524d81c..25c4d66 100644 --- a/src/newsreader/news/core/templates/core/category-update.html +++ b/src/newsreader/news/core/templates/core/category-update.html @@ -1,7 +1,7 @@ {% extends "core/category.html" %} {% block form-header %} -

    Update category

    +

    Update category

    {% endblock %} {% block name-input %} diff --git a/src/newsreader/scss/components/body/_body.scss b/src/newsreader/scss/components/body/_body.scss index d4316d3..306ad7c 100644 --- a/src/newsreader/scss/components/body/_body.scss +++ b/src/newsreader/scss/components/body/_body.scss @@ -5,10 +5,15 @@ font-family: $default-font; color: $default-font-color; +} + +body { + @extend .body; & * { margin: 0; padding: 0; box-sizing: border-box; } + } diff --git a/src/newsreader/scss/components/fieldset/_fieldset.scss b/src/newsreader/scss/components/fieldset/_fieldset.scss new file mode 100644 index 0000000..c2588b5 --- /dev/null +++ b/src/newsreader/scss/components/fieldset/_fieldset.scss @@ -0,0 +1,11 @@ +.fieldset { + display: flex; + flex-direction: column; + + padding: 15px; + border: none; +} + +fieldset { + @extend .fieldset; +} diff --git a/src/newsreader/scss/components/fieldset/index.scss b/src/newsreader/scss/components/fieldset/index.scss new file mode 100644 index 0000000..be990a8 --- /dev/null +++ b/src/newsreader/scss/components/fieldset/index.scss @@ -0,0 +1 @@ +@import "fieldset"; diff --git a/src/newsreader/scss/components/form/_form.scss b/src/newsreader/scss/components/form/_form.scss index d806a7f..8c51866 100644 --- a/src/newsreader/scss/components/form/_form.scss +++ b/src/newsreader/scss/components/form/_form.scss @@ -8,11 +8,7 @@ background-color: $white; &__fieldset { - display: flex; - flex-direction: column; - - padding: 15px; - border: none; + @extend .fieldset; } &__header { @@ -22,7 +18,15 @@ padding: 15px; } + &__title { + font-size: 18px; + } + & .favicon { height: 30px; } } + +form { + @extend .form; +} diff --git a/src/newsreader/scss/components/index.scss b/src/newsreader/scss/components/index.scss index f52df7a..6a18d4a 100644 --- a/src/newsreader/scss/components/index.scss +++ b/src/newsreader/scss/components/index.scss @@ -10,3 +10,4 @@ @import "./messages/index"; @import "./section/index"; @import "./errorlist/index"; +@import "./fieldset/index"; diff --git a/src/newsreader/scss/components/list/_list.scss b/src/newsreader/scss/components/list/_list.scss index 0e4666c..75e5e94 100644 --- a/src/newsreader/scss/components/list/_list.scss +++ b/src/newsreader/scss/components/list/_list.scss @@ -14,3 +14,7 @@ } } } + +ul { + @extend .list; +} diff --git a/src/newsreader/scss/elements/a/_a.scss b/src/newsreader/scss/elements/a/_a.scss deleted file mode 100644 index 69113c5..0000000 --- a/src/newsreader/scss/elements/a/_a.scss +++ /dev/null @@ -1,3 +0,0 @@ -a { - @extend .link; -} diff --git a/src/newsreader/scss/elements/a/index.scss b/src/newsreader/scss/elements/a/index.scss deleted file mode 100644 index 1bf17d2..0000000 --- a/src/newsreader/scss/elements/a/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "a"; diff --git a/src/newsreader/scss/elements/help-text/_help-text.scss b/src/newsreader/scss/elements/help-text/_help-text.scss index 6c5fc9b..a90552d 100644 --- a/src/newsreader/scss/elements/help-text/_help-text.scss +++ b/src/newsreader/scss/elements/help-text/_help-text.scss @@ -1,5 +1,9 @@ .help-text { - @extends .small; + @extend .small; padding: 5px 15px; } + +.helptext { + @extend .help-text; +} diff --git a/src/newsreader/scss/elements/index.scss b/src/newsreader/scss/elements/index.scss index cc587d8..46a8bbd 100644 --- a/src/newsreader/scss/elements/index.scss +++ b/src/newsreader/scss/elements/index.scss @@ -1,6 +1,5 @@ -@import "./button/index"; +@import "button/index"; @import "link/index"; -@import "a/index"; @import "h1/index"; @import "h2/index"; @import "h3/index"; diff --git a/src/newsreader/scss/elements/input/_input.scss b/src/newsreader/scss/elements/input/_input.scss index 8cf2340..1cfb4bb 100644 --- a/src/newsreader/scss/elements/input/_input.scss +++ b/src/newsreader/scss/elements/input/_input.scss @@ -1,6 +1,7 @@ .input { padding: 10px; + background-color: lighten($gainsboro, +4%); border: 1px $border-gray solid; border-radius: 2px; @@ -8,3 +9,7 @@ border: 1px $focus-blue solid; } } + +input { + @extend .input; +} diff --git a/src/newsreader/scss/elements/label/_label.scss b/src/newsreader/scss/elements/label/_label.scss index 8c8261c..5030a4c 100644 --- a/src/newsreader/scss/elements/label/_label.scss +++ b/src/newsreader/scss/elements/label/_label.scss @@ -1,3 +1,7 @@ .label { padding: 10px; } + +label { + @extend .label; +} diff --git a/src/newsreader/scss/elements/link/_link.scss b/src/newsreader/scss/elements/link/_link.scss index 96f737f..b485cb3 100644 --- a/src/newsreader/scss/elements/link/_link.scss +++ b/src/newsreader/scss/elements/link/_link.scss @@ -6,3 +6,7 @@ cursor: pointer; } } + +a { + @extend .link; +} diff --git a/src/newsreader/scss/elements/small/_small.scss b/src/newsreader/scss/elements/small/_small.scss index 59830e4..c95bfab 100644 --- a/src/newsreader/scss/elements/small/_small.scss +++ b/src/newsreader/scss/elements/small/_small.scss @@ -1,3 +1,8 @@ .small { color: $nickel; + font-size: small; +} + +small { + @extend .small; } diff --git a/src/newsreader/scss/pages/activate/components/index.scss b/src/newsreader/scss/pages/activate/components/index.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/scss/pages/activate/elements/index.scss b/src/newsreader/scss/pages/activate/elements/index.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/scss/pages/activate/index.scss b/src/newsreader/scss/pages/activate/index.scss new file mode 100644 index 0000000..16b6493 --- /dev/null +++ b/src/newsreader/scss/pages/activate/index.scss @@ -0,0 +1,8 @@ +// General imports +@import "../../partials/variables"; +@import "../../components/index"; +@import "../../elements/index"; + +// Page specific +@import "./components/index"; +@import "./elements/index"; diff --git a/src/newsreader/scss/pages/password-reset/components/index.scss b/src/newsreader/scss/pages/password-reset/components/index.scss new file mode 100644 index 0000000..4536bb6 --- /dev/null +++ b/src/newsreader/scss/pages/password-reset/components/index.scss @@ -0,0 +1,2 @@ +@import "password-reset-form/index"; +@import "password-reset-confirm-form/index"; diff --git a/src/newsreader/scss/pages/password-reset/components/password-reset-confirm-form/_password-reset-confirm-form.scss b/src/newsreader/scss/pages/password-reset/components/password-reset-confirm-form/_password-reset-confirm-form.scss new file mode 100644 index 0000000..d570c38 --- /dev/null +++ b/src/newsreader/scss/pages/password-reset/components/password-reset-confirm-form/_password-reset-confirm-form.scss @@ -0,0 +1,3 @@ +.password-reset-confirm-form { + margin: 20px 0; +} diff --git a/src/newsreader/scss/pages/password-reset/components/password-reset-confirm-form/index.scss b/src/newsreader/scss/pages/password-reset/components/password-reset-confirm-form/index.scss new file mode 100644 index 0000000..2448efe --- /dev/null +++ b/src/newsreader/scss/pages/password-reset/components/password-reset-confirm-form/index.scss @@ -0,0 +1 @@ +@import "password-reset-confirm-form"; diff --git a/src/newsreader/scss/pages/password-reset/components/password-reset-form/_password-reset-form.scss b/src/newsreader/scss/pages/password-reset/components/password-reset-form/_password-reset-form.scss new file mode 100644 index 0000000..be92ff4 --- /dev/null +++ b/src/newsreader/scss/pages/password-reset/components/password-reset-form/_password-reset-form.scss @@ -0,0 +1,18 @@ +.password-reset-form { + margin: 20px 0; + + &__fieldset:last-child { + display: flex; + flex-direction: row; + justify-content: space-between; + } + + & .form__header { + display: flex; + flex-direction: column; + } + + & .form__title { + margin: 0 0 5px 0; + } +} diff --git a/src/newsreader/scss/pages/password-reset/components/password-reset-form/index.scss b/src/newsreader/scss/pages/password-reset/components/password-reset-form/index.scss new file mode 100644 index 0000000..1d60faf --- /dev/null +++ b/src/newsreader/scss/pages/password-reset/components/password-reset-form/index.scss @@ -0,0 +1 @@ +@import "password-reset-form"; diff --git a/src/newsreader/scss/pages/password-reset/elements/index.scss b/src/newsreader/scss/pages/password-reset/elements/index.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/scss/pages/password-reset/index.scss b/src/newsreader/scss/pages/password-reset/index.scss new file mode 100644 index 0000000..16b6493 --- /dev/null +++ b/src/newsreader/scss/pages/password-reset/index.scss @@ -0,0 +1,8 @@ +// General imports +@import "../../partials/variables"; +@import "../../components/index"; +@import "../../elements/index"; + +// Page specific +@import "./components/index"; +@import "./elements/index"; diff --git a/src/newsreader/scss/pages/register/components/activation-form/_activation-form.scss b/src/newsreader/scss/pages/register/components/activation-form/_activation-form.scss new file mode 100644 index 0000000..39ecc27 --- /dev/null +++ b/src/newsreader/scss/pages/register/components/activation-form/_activation-form.scss @@ -0,0 +1,11 @@ +.activation-form { + margin: 10px 0; + & h4 { + padding: 20px 24px 5px 24px; + } + + &__fieldset:last-child { + flex-direction: row; + justify-content: space-between; + } +} diff --git a/src/newsreader/scss/pages/register/components/activation-form/index.scss b/src/newsreader/scss/pages/register/components/activation-form/index.scss new file mode 100644 index 0000000..748302f --- /dev/null +++ b/src/newsreader/scss/pages/register/components/activation-form/index.scss @@ -0,0 +1 @@ +@import "activation-form"; diff --git a/src/newsreader/scss/pages/register/components/index.scss b/src/newsreader/scss/pages/register/components/index.scss new file mode 100644 index 0000000..3377ff1 --- /dev/null +++ b/src/newsreader/scss/pages/register/components/index.scss @@ -0,0 +1,2 @@ +@import "register-form/index"; +@import "activation-form/index"; diff --git a/src/newsreader/scss/pages/register/components/register-form/_register-form.scss b/src/newsreader/scss/pages/register/components/register-form/_register-form.scss new file mode 100644 index 0000000..e406ae7 --- /dev/null +++ b/src/newsreader/scss/pages/register/components/register-form/_register-form.scss @@ -0,0 +1,11 @@ +.register-form { + margin: 10px 0; + & h4 { + padding: 20px 24px 5px 24px; + } + + &__fieldset:last-child { + flex-direction: row; + justify-content: space-between; + } +} diff --git a/src/newsreader/scss/pages/register/components/register-form/index.scss b/src/newsreader/scss/pages/register/components/register-form/index.scss new file mode 100644 index 0000000..f0d9b70 --- /dev/null +++ b/src/newsreader/scss/pages/register/components/register-form/index.scss @@ -0,0 +1 @@ +@import "register-form"; diff --git a/src/newsreader/scss/pages/register/elements/index.scss b/src/newsreader/scss/pages/register/elements/index.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/scss/pages/register/index.scss b/src/newsreader/scss/pages/register/index.scss new file mode 100644 index 0000000..16b6493 --- /dev/null +++ b/src/newsreader/scss/pages/register/index.scss @@ -0,0 +1,8 @@ +// General imports +@import "../../partials/variables"; +@import "../../components/index"; +@import "../../elements/index"; + +// Page specific +@import "./components/index"; +@import "./elements/index"; diff --git a/src/newsreader/templates/base.html b/src/newsreader/templates/base.html index 7346a66..445d507 100644 --- a/src/newsreader/templates/base.html +++ b/src/newsreader/templates/base.html @@ -16,6 +16,7 @@ {% else %} + {% endif %} diff --git a/src/newsreader/templates/password-reset/password_reset_complete.html b/src/newsreader/templates/password-reset/password_reset_complete.html new file mode 100755 index 0000000..80c2b69 --- /dev/null +++ b/src/newsreader/templates/password-reset/password_reset_complete.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% load static i18n %} + +{% block title %}{% trans "Password reset complete" %}{% endblock %} + +{% block head %} + +{% endblock %} + + +{% block content %} +
    +
    +
    +

    {% trans "Password reset complete" %}

    +
    +
    +

    + {% trans "Your password has been reset!" %} + {% blocktrans %} + You may now log in + {% endblocktrans %}. +

    +
    + +
    +{% endblock %} diff --git a/src/newsreader/templates/password-reset/password_reset_confirm.html b/src/newsreader/templates/password-reset/password_reset_confirm.html new file mode 100755 index 0000000..ff6883f --- /dev/null +++ b/src/newsreader/templates/password-reset/password_reset_confirm.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% load static i18n %} + +{% block meta %} + + +{% endblock %} + +{% block title %}{% trans "Confirm password reset" %}{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
    + + {% if validlink %} +
    + {% csrf_token %} +
    +

    + {% trans "Enter your new password below to reset your password:" %} +

    +
    + +
    + {{ form }} +
    +
    + Cancel + +
    +
    + + {% else %} +
    +
    +

    {% trans "Password reset unsuccessful" %}

    +
    +
    +

    + {% url 'accounts:password-reset' as reset_url %} + {% blocktrans %} + Password reset unsuccessful. Please + try again. + {% endblocktrans %} +

    +
    + + {% endif %} + +
    +{% endblock %} + + +{# This is used by django.contrib.auth #} diff --git a/src/newsreader/templates/password-reset/password_reset_done.html b/src/newsreader/templates/password-reset/password_reset_done.html new file mode 100755 index 0000000..11a59ff --- /dev/null +++ b/src/newsreader/templates/password-reset/password_reset_done.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% load static i18n %} + +{% block title %}{% trans "Password reset" %}{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
    +
    +
    +

    {% trans "Password reset" %}

    +
    +
    +

    + {% blocktrans %} + We have sent you an email with a link to reset your password. Please check + your email and click the link to continue. + {% endblocktrans %} +

    +
    + +
    +{% endblock %} diff --git a/src/newsreader/templates/password-reset/password_reset_email.html b/src/newsreader/templates/password-reset/password_reset_email.html new file mode 100755 index 0000000..69844e8 --- /dev/null +++ b/src/newsreader/templates/password-reset/password_reset_email.html @@ -0,0 +1,30 @@ +{% load i18n %} + +{% blocktrans %}Greetings{% endblocktrans %} {% if user.get_full_name %}{{ user.get_full_name }}{% else %}{{ user }}{% endif %}, + +{% blocktrans %} +You are receiving this email because you (or someone pretending to be you) +requested that your password be reset on the {{ domain }} site. If you do not +wish to reset your password, please ignore this message. +{% endblocktrans %} + +{% blocktrans %} +To reset your password, please click the following link, or copy and paste it +into your web browser: +{% endblocktrans %} + + + {{ protocol }}://{{ domain }}{% url 'accounts:password-reset-confirm' uid token %} + + +{% blocktrans %} + Your username, in case you've forgotten: +{% endblocktrans %} {{ user.get_username }} + +{% blocktrans %} + Best regards +{% endblocktrans %}, +{{ site_name }} +{% blocktrans %} + Management +{% endblocktrans %} diff --git a/src/newsreader/templates/password-reset/password_reset_form.html b/src/newsreader/templates/password-reset/password_reset_form.html new file mode 100755 index 0000000..2ceb647 --- /dev/null +++ b/src/newsreader/templates/password-reset/password_reset_form.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% load static i18n %} + +{% block title %}{% trans "Reset password" %}{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
    +
    + {% csrf_token %} +
    +

    {% trans "Reset password" %}

    + +

    + {% blocktrans %} + Forgot your password? Enter your email in the form below and we'll send you + instructions for creating a new one. + {% endblocktrans %} +

    +
    + +
    + {{ form }} +
    +
    + Cancel + +
    +
    +
    +{% endblock %} diff --git a/src/newsreader/templates/password-reset/password_reset_subject.txt b/src/newsreader/templates/password-reset/password_reset_subject.txt new file mode 100644 index 0000000..bbf2b7e --- /dev/null +++ b/src/newsreader/templates/password-reset/password_reset_subject.txt @@ -0,0 +1,6 @@ +{% load i18n %}{% trans "Password reset on" %} {{ site_name }} + +{% comment %} +See the save method in the PasswordChangeForm in django/contrib/auth/forms.py +for the available context data +{% endcomment %} diff --git a/src/newsreader/templates/registration/activation_complete.html b/src/newsreader/templates/registration/activation_complete.html new file mode 100755 index 0000000..77561ef --- /dev/null +++ b/src/newsreader/templates/registration/activation_complete.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% load static i18n %} + +{% block title %}{% trans "Account Activated" %}{% endblock %} + +{% block head %} + +{% endblock %} + +{% comment %} +**registration/activation_complete.html** + +Used after successful account activation. This template has no context +variables of its own, and should simply inform the user that their +account is now active. +{% endcomment %} + +{% block content %} +
    +
    +
    +

    {% trans "Account activated" %}

    +
    +
    +

    + {% trans "Your account is now activated." %} + {% if not user.is_authenticated %} + {% trans "You can log in." %} + {% endif %} +

    +
    + +
    +{% endblock %} diff --git a/src/newsreader/templates/registration/activation_email.html b/src/newsreader/templates/registration/activation_email.html new file mode 100644 index 0000000..8be4421 --- /dev/null +++ b/src/newsreader/templates/registration/activation_email.html @@ -0,0 +1,72 @@ +{% load i18n %} + + + + + {{ site.name }} {% trans "registration" %} + + + +

    + {% blocktrans with site_name=site.name %} + You (or someone pretending to be you) have asked to register an account at + {{ site_name }}. If this wasn't you, please ignore this email + and your address will be removed from our records. + {% endblocktrans %} +

    +

    + {% blocktrans %} + To activate this account, please click the following link within the next + {{ expiration_days }} days: + {% endblocktrans %} +

    + +

    + + {{site.domain}}{% url 'accounts:activate' activation_key %} + +

    +

    + {% blocktrans with site_name=site.name %} + Sincerely, + {{ site_name }} Management + {% endblocktrans %} +

    + + + + + +{% comment %} +**registration/activation_email.html** + +Used to generate the html body of the activation email. Should display a +link the user can click to activate the account. This template has the +following context: + +``activation_key`` + The activation key for the new account. + +``expiration_days`` + The number of days remaining during which the account may be + activated. + +``site`` + An object representing the site on which the user registered; + depending on whether ``django.contrib.sites`` is installed, this + may be an instance of either ``django.contrib.sites.models.Site`` + (if the sites application is installed) or + ``django.contrib.sites.requests.RequestSite`` (if not). Consult `the + documentation for the Django sites framework + `_ for + details regarding these objects' interfaces. + +``user`` + The new user account + +``request`` + ``HttpRequest`` instance for better flexibility. + For example it can be used to compute absolute register URL: + + {{ request.scheme }}://{{ request.get_host }}{% url 'registration_activate' activation_key %} +{% endcomment %} diff --git a/src/newsreader/templates/registration/activation_email.txt b/src/newsreader/templates/registration/activation_email.txt new file mode 100644 index 0000000..7f52a60 --- /dev/null +++ b/src/newsreader/templates/registration/activation_email.txt @@ -0,0 +1,52 @@ +{% load i18n %} +{% blocktrans with site_name=site.name %} +You (or someone pretending to be you) have asked to register an account at +{{ site_name }}. If this wasn't you, please ignore this email +and your address will be removed from our records. +{% endblocktrans %} +{% blocktrans %} +To activate this account, please click the following link within the next +{{ expiration_days }} days: +{% endblocktrans %} + +http://{{site.domain}}{% url 'accounts:activate' activation_key %} + +{% blocktrans with site_name=site.name %} +Sincerely, +{{ site_name }} Management +{% endblocktrans %} + + +{% comment %} +**registration/activation_email.txt** + +Used to generate the text body of the activation email. Should display a +link the user can click to activate the account. This template has the +following context: + +``activation_key`` + The activation key for the new account. + +``expiration_days`` + The number of days remaining during which the account may be + activated. + +``site`` + An object representing the site on which the user registered; + depending on whether ``django.contrib.sites`` is installed, this + may be an instance of either ``django.contrib.sites.models.Site`` + (if the sites application is installed) or + ``django.contrib.sites.requests.RequestSite`` (if not). Consult `the + documentation for the Django sites framework + `_ for + details regarding these objects' interfaces. + +``user`` + The new user account + +``request`` + ``HttpRequest`` instance for better flexibility. + For example it can be used to compute absolute register URL: + + {{ request.scheme }}://{{ request.get_host }}{% url 'registration_activate' activation_key %} +{% endcomment %} diff --git a/src/newsreader/templates/registration/activation_email_subject.txt b/src/newsreader/templates/registration/activation_email_subject.txt new file mode 100644 index 0000000..da0ddeb --- /dev/null +++ b/src/newsreader/templates/registration/activation_email_subject.txt @@ -0,0 +1,28 @@ +{% load i18n %}{% trans "Account activation on" %} {{ site.name }} + + +{% comment %} +**registration/activation_email_subject.txt** + +Used to generate the subject line of the activation email. Because the +subject line of an email must be a single line of text, any output +from this template will be forcibly condensed to a single line before +being used. This template has the following context: + +``activation_key`` + The activation key for the new account. + +``expiration_days`` + The number of days remaining during which the account may be + activated. + +``site`` + An object representing the site on which the user registered; + depending on whether ``django.contrib.sites`` is installed, this + may be an instance of either ``django.contrib.sites.models.Site`` + (if the sites application is installed) or + ``django.contrib.sites.requests.RequestSite`` (if not). Consult `the + documentation for the Django sites framework + `_ for + details regarding these objects' interfaces. +{% endcomment %} diff --git a/src/newsreader/templates/registration/activation_failure.html b/src/newsreader/templates/registration/activation_failure.html new file mode 100644 index 0000000..88c9053 --- /dev/null +++ b/src/newsreader/templates/registration/activation_failure.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% load static i18n %} + +{% block title %}{% trans "Activation Failure" %}{% endblock %} + +{% block head %} + +{% endblock %} + +{% comment %} +**registration/activate.html** + +Used if account activation fails. With the default setup, has the following context: + +``activation_key`` + The activation key used during the activation attempt. +{% endcomment %} + +{% block content %} +
    +
    +
    +

    {% trans "Activation Failure" %}

    +
    +
    +

    {% trans "Account activation failed." %}

    +
    + +
    +{% endblock %} diff --git a/src/newsreader/templates/registration/activation_resend_complete.html b/src/newsreader/templates/registration/activation_resend_complete.html new file mode 100644 index 0000000..0b63c89 --- /dev/null +++ b/src/newsreader/templates/registration/activation_resend_complete.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% load static i18n %} + +{% block title %}{% trans "Account Activation Resent" %}{% endblock %} + +{% block head %} + +{% endblock %} + +{% comment %} +**registration/resend_activation_complete.html** +Used after form for resending account activation is submitted. By default has +the following context: + +``email`` + The email address submitted in the resend activation form. +{% endcomment %} + +{% block content %} +
    +
    +
    +

    {% trans "Account activation resent" %}

    +
    +
    +

    + {% blocktrans %} + We have sent an email to {{ email }} with further instructions. + {% endblocktrans %} +

    +
    + +
    +{% endblock %} diff --git a/src/newsreader/templates/registration/activation_resend_form.html b/src/newsreader/templates/registration/activation_resend_form.html new file mode 100644 index 0000000..e819a1f --- /dev/null +++ b/src/newsreader/templates/registration/activation_resend_form.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% load static i18n %} + +{% block title %}{% trans "Resend Activation Email" %}{% endblock %} + +{% block head %} + +{% endblock %} + +{% comment %} +**registration/resend_activation_form.html** +Used to show the form users will fill out to resend the activation email. By +default, has the following context: + +``form`` + The registration form. This will be an instance of some subclass + of ``django.forms.Form``; consult `Django's forms documentation + `_ for + information on how to display this in a template. +{% endcomment %} + +{% block content %} +
    +
    + {% csrf_token %} +
    +

    Resend activation code

    +
    + +
    + {{ form }} +
    +
    + Cancel + +
    +
    +
    +{% endblock %} diff --git a/src/newsreader/templates/registration/registration_closed.html b/src/newsreader/templates/registration/registration_closed.html new file mode 100755 index 0000000..1ac2ad5 --- /dev/null +++ b/src/newsreader/templates/registration/registration_closed.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% load static i18n %} + +{% block title %}{% trans "Registration is closed" %}{% endblock %} + +{% block head %} + +{% endblock %} + + +{% block content %} +
    +
    +
    +

    {% trans "Registration is closed" %}

    +
    +
    +

    + {% trans "Sorry, but registration is closed at this moment. Come back later." %} +

    +
    + +
    +{% endblock %} diff --git a/src/newsreader/templates/registration/registration_complete.html b/src/newsreader/templates/registration/registration_complete.html new file mode 100755 index 0000000..6e508a2 --- /dev/null +++ b/src/newsreader/templates/registration/registration_complete.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% load static i18n %} + +{% block title %}{% trans "Activation email sent" %}{% endblock %} + +{% block head %} + +{% endblock %} + +{% comment %} +**registration/registration_complete.html** + +Used after successful completion of the registration form. This +template has no context variables of its own, and should simply inform +the user that an email containing account-activation information has +been sent. +{% endcomment %} + +{% block content %} +
    +
    +
    +

    {% trans "Activation email sent" %}

    +
    +
    +

    + {% trans "Please check your email to complete the registration process." %} +

    +
    + +
    +{% endblock %} diff --git a/src/newsreader/templates/registration/registration_form.html b/src/newsreader/templates/registration/registration_form.html new file mode 100644 index 0000000..d5e8a13 --- /dev/null +++ b/src/newsreader/templates/registration/registration_form.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} + +{% load static %} + +{% block head %} + +{% endblock %} + +{% block content %} +
    +
    + {% csrf_token %} +
    +

    Register

    +
    + +
    + {{ form }} +
    +
    + Cancel + +
    +
    +
    +{% endblock %} From a9edd520a71ec84681213b92442204f8b8a75d26 Mon Sep 17 00:00:00 2001 From: Sonny Date: Thu, 28 Nov 2019 21:18:45 +0100 Subject: [PATCH 028/422] Fix negative unread count --- .../js/pages/homepage/reducers/categories.js | 52 ++++++++----------- .../js/pages/homepage/reducers/selected.js | 6 +++ 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/newsreader/js/pages/homepage/reducers/categories.js b/src/newsreader/js/pages/homepage/reducers/categories.js index 4fd391f..90fe063 100644 --- a/src/newsreader/js/pages/homepage/reducers/categories.js +++ b/src/newsreader/js/pages/homepage/reducers/categories.js @@ -12,33 +12,6 @@ import { import { MARK_POST_READ } from '../actions/posts.js'; import { MARK_SECTION_READ } from '../actions/selected.js'; -const markCategoryRead = (action, state) => { - const category = { ...state.items[action.section.id] }; - - return { - ...state, - items: { - ...state.items, - [category.id]: { ...category, unread: 0 }, - }, - }; -}; - -const markCategoryByRule = (action, state) => { - const category = { ...state.items[action.section.category] }; - - return { - ...state, - items: { - ...state.items, - [category.id]: { - ...category, - unread: category.unread - action.section.unread, - }, - }, - }; -}; - const defaultState = { items: {}, isFetching: false }; export const categories = (state = { ...defaultState }, action) => { @@ -83,11 +56,32 @@ export const categories = (state = { ...defaultState }, action) => { }, }; case MARK_SECTION_READ: + category = {}; + switch (action.section.type) { case CATEGORY_TYPE: - return markCategoryRead(action, state); + category = { ...state.items[action.section.id] }; + + return { + ...state, + items: { + ...state.items, + [category.id]: { ...category, unread: 0 }, + }, + }; case RULE_TYPE: - return markCategoryByRule(action, state); + category = { ...state.items[action.section.category] }; + + return { + ...state, + items: { + ...state.items, + [category.id]: { + ...category, + unread: category.unread - action.section.unread, + }, + }, + }; } return state; diff --git a/src/newsreader/js/pages/homepage/reducers/selected.js b/src/newsreader/js/pages/homepage/reducers/selected.js index 0bef90c..8b1f7f8 100644 --- a/src/newsreader/js/pages/homepage/reducers/selected.js +++ b/src/newsreader/js/pages/homepage/reducers/selected.js @@ -10,6 +10,7 @@ import { } from '../actions/posts.js'; import { MARK_SECTION_READ } from '../actions/selected.js'; +import { MARK_POST_READ } from '../actions/posts.js'; const defaultState = { item: {}, next: false, lastReached: false, post: {} }; @@ -75,6 +76,11 @@ export const selected = (state = { ...defaultState }, action) => { ...state, post: {}, }; + case MARK_POST_READ: + return { + ...state, + item: { ...action.section, unread: action.section.unread - 1 }, + }; case MARK_SECTION_READ: return { ...state, From 08e4d043b91eca006544c579262f72f02f8f471b Mon Sep 17 00:00:00 2001 From: Sonny Date: Fri, 29 Nov 2019 23:15:09 +0100 Subject: [PATCH 029/422] Update default fixture --- src/newsreader/fixtures/default-fixture.json | 555 +++++++++---------- 1 file changed, 275 insertions(+), 280 deletions(-) diff --git a/src/newsreader/fixtures/default-fixture.json b/src/newsreader/fixtures/default-fixture.json index e0ed80f..7639b81 100644 --- a/src/newsreader/fixtures/default-fixture.json +++ b/src/newsreader/fixtures/default-fixture.json @@ -1,282 +1,277 @@ [ - { - "fields" : { - "last_login" : "2019-08-10T18:07:27.224Z", - "last_name" : "", - "is_superuser" : true, - "is_staff" : true, - "task_interval" : null, - "email" : "sonny@newsreader.nl", - "user_permissions" : [], - "groups" : [], - "password" : "pbkdf2_sha256$150000$OeNoz2LRSpI5$jkkUf/BjTuWZULyldvNTt9f45/ErxaCmCQHfZwtzji8=", - "first_name" : "", - "task" : null, - "is_active" : true, - "date_joined" : "2019-08-10T18:07:19.699Z" - }, - "pk" : 1, - "model" : "accounts.user" - }, - { - "pk" : 1, - "model" : "django_celery_beat.intervalschedule", - "fields" : { - "period" : "minutes", - "every" : 5 - } - }, - { - "pk" : 2, - "model" : "django_celery_beat.intervalschedule", - "fields" : { - "every" : 30, - "period" : "minutes" - } - }, - { - "fields" : { - "every" : 3, - "period" : "hours" - }, - "model" : "django_celery_beat.intervalschedule", - "pk" : 3 - }, - { - "fields" : { - "minute" : "0", - "timezone" : "UTC", - "day_of_month" : "*", - "hour" : "4", - "month_of_year" : "*", - "day_of_week" : "*" - }, - "model" : "django_celery_beat.crontabschedule", - "pk" : 1 - }, - { - "fields" : { - "last_update" : "2019-08-10T20:01:21.152Z" - }, - "model" : "django_celery_beat.periodictasks", - "pk" : 1 - }, - { - "model" : "django_celery_beat.periodictask", - "pk" : 1, - "fields" : { - "description" : "", - "last_run_at" : null, - "routing_key" : null, - "kwargs" : "{}", - "queue" : null, - "name" : "celery.backend_cleanup", - "clocked" : null, - "solar" : null, - "task" : "celery.backend_cleanup", - "one_off" : false, - "expires" : null, - "total_run_count" : 0, - "date_changed" : "2019-08-10T19:56:33.160Z", - "args" : "[]", - "start_time" : null, - "priority" : null, - "crontab" : 1, - "interval" : null, - "enabled" : true, - "headers" : "{}", - "exchange" : null - } - }, - { - "pk" : 3, - "model" : "django_celery_beat.periodictask", - "fields" : { - "exchange" : null, - "headers" : "{}", - "enabled" : true, - "priority" : null, - "crontab" : null, - "interval" : 3, - "start_time" : "2019-08-10T20:00:52Z", - "args" : "[1]", - "date_changed" : "2019-08-10T20:01:21.153Z", - "total_run_count" : 0, - "expires" : null, - "task" : "newsreader.news.collection.tasks.collect", - "one_off" : false, - "clocked" : null, - "solar" : null, - "name" : "Collection testing task", - "queue" : null, - "kwargs" : "{}", - "routing_key" : null, - "last_run_at" : null, - "description" : "" - } - }, - { - "fields" : { - "user" : 1, - "created" : "2019-08-10T19:57:53Z", - "name" : "Tech", - "modified" : "2019-08-10T19:58:02.048Z" - }, - "pk" : 1, - "model" : "core.category" - }, - { - "fields" : { - "modified" : "2019-08-10T19:58:20.951Z", - "user" : 1, - "created" : "2019-08-10T19:58:13Z", - "name" : "News" - }, - "pk" : 2, - "model" : "core.category" - }, - { - "pk" : 1, - "model" : "collection.collectionrule", - "fields" : { - "timezone" : "Europe/Amsterdam", - "succeeded" : true, - "error" : null, - "website_url" : null, - "favicon" : null, - "category" : 1, - "created" : "2019-08-10T18:08:10.520Z", - "user" : 1, - "name" : "Tweakers", - "url" : "http://feeds.feedburner.com/tweakers/mixed", - "last_suceeded" : "2019-08-10T18:25:32.325Z", - "modified" : "2019-08-10T19:59:54.547Z" - } - }, - { - "fields" : { - "favicon" : null, - "category" : 1, - "timezone" : "UTC", - "error" : null, - "website_url" : null, - "succeeded" : false, - "modified" : "2019-08-10T19:58:05.691Z", - "last_suceeded" : null, - "name" : "Hackers News", - "created" : "2019-08-10T19:58:05.615Z", - "user" : 1, - "url" : "https://news.ycombinator.com/rss" - }, - "pk" : 2, - "model" : "collection.collectionrule" - }, - { - "fields" : { - "timezone" : "UTC", - "error" : null, - "website_url" : null, - "succeeded" : false, - "favicon" : null, - "category" : 2, - "name" : "BBC", - "created" : "2019-08-10T19:58:23.441Z", - "user" : 1, - "url" : "http://feeds.bbci.co.uk/news/world/rss.xml", - "modified" : "2019-08-10T19:58:23.449Z", - "last_suceeded" : null - }, - "pk" : 3, - "model" : "collection.collectionrule" - }, - { - "fields" : { - "favicon" : null, - "category" : 1, - "timezone" : "UTC", - "succeeded" : false, - "error" : null, - "website_url" : null, - "last_suceeded" : null, - "modified" : "2019-08-10T19:58:31.873Z", - "user" : 1, - "created" : "2019-08-10T19:58:31.867Z", - "name" : "Ars Technica", - "url" : "http://feeds.arstechnica.com/arstechnica/index?fmt=xml" - }, - "model" : "collection.collectionrule", - "pk" : 4 - }, - { - "pk" : 5, - "model" : "collection.collectionrule", - "fields" : { - "last_suceeded" : null, - "modified" : "2019-08-10T19:58:48.535Z", - "url" : "https://www.theguardian.com/world/rss", - "created" : "2019-08-10T19:58:48.529Z", - "user" : 1, - "name" : "The Guardian", - "category" : 2, - "favicon" : null, - "succeeded" : false, - "error" : null, - "website_url" : null, - "timezone" : "UTC" - } - }, - { - "model" : "collection.collectionrule", - "pk" : 6, - "fields" : { - "error" : null, - "website_url" : null, - "succeeded" : false, - "timezone" : "UTC", - "category" : 1, - "favicon" : null, - "url" : "https://www.engadget.com/rss.xml", - "name" : "Engadget", - "created" : "2019-08-10T19:58:58.641Z", - "user" : 1, - "modified" : "2019-08-10T19:58:58.647Z", - "last_suceeded" : null - } - }, - { - "fields" : { - "last_suceeded" : null, - "modified" : "2019-08-10T19:59:29.917Z", - "user" : 1, - "created" : "2019-08-10T19:59:29.909Z", - "name" : "The Verge", - "url" : "https://www.theverge.com/rss/index.xml", - "favicon" : null, - "category" : 1, - "timezone" : "UTC", - "succeeded" : false, - "error" : null, - "website_url" : null - }, - "model" : "collection.collectionrule", - "pk" : 7 - }, - { - "pk" : 8, - "model" : "collection.collectionrule", - "fields" : { - "modified" : "2019-08-10T19:59:44.838Z", - "last_suceeded" : null, - "name" : "News", - "user" : 1, - "created" : "2019-08-10T19:59:44.833Z", - "url" : "http://feeds.boingboing.net/boingboing/iBag", - "favicon" : null, - "category" : 2, - "timezone" : "UTC", - "website_url" : null, - "error" : null, - "succeeded" : false - } - } +{ + "model": "django_celery_beat.periodictask", + "pk": 2, + "fields": { + "name": "celery.backend_cleanup", + "task": "celery.backend_cleanup", + "interval": null, + "crontab": 1, + "solar": null, + "clocked": null, + "args": "[]", + "kwargs": "{}", + "queue": null, + "exchange": null, + "routing_key": null, + "headers": "{}", + "priority": null, + "expires": null, + "one_off": false, + "start_time": null, + "enabled": true, + "last_run_at": "2019-11-29T04:00:00.000Z", + "total_run_count": 17, + "date_changed": "2019-11-29T04:02:15.258Z", + "description": "" + } +}, +{ + "model": "django_celery_beat.periodictask", + "pk": 10, + "fields": { + "name": "sonny@bakker.nl-collection-task", + "task": "newsreader.news.collection.tasks.collect", + "interval": 4, + "crontab": null, + "solar": null, + "clocked": null, + "args": "[2]", + "kwargs": "{}", + "queue": null, + "exchange": null, + "routing_key": null, + "headers": "{}", + "priority": null, + "expires": null, + "one_off": false, + "start_time": null, + "enabled": true, + "last_run_at": "2019-11-29T22:29:08.345Z", + "total_run_count": 290, + "date_changed": "2019-11-29T22:29:18.378Z", + "description": "" + } +}, +{ + "model": "django_celery_beat.periodictask", + "pk": 26, + "fields": { + "name": "sonnyba871@gmail.com-collection-task", + "task": "newsreader.news.collection.tasks.collect", + "interval": 4, + "crontab": null, + "solar": null, + "clocked": null, + "args": "[18]", + "kwargs": "{}", + "queue": null, + "exchange": null, + "routing_key": null, + "headers": "{}", + "priority": null, + "expires": null, + "one_off": false, + "start_time": null, + "enabled": true, + "last_run_at": "2019-11-29T22:35:19.134Z", + "total_run_count": 103, + "date_changed": "2019-11-29T22:38:19.464Z", + "description": "" + } +}, +{ + "model": "django_celery_beat.crontabschedule", + "pk": 1, + "fields": { + "minute": "0", + "hour": "4", + "day_of_week": "*", + "day_of_month": "*", + "month_of_year": "*", + "timezone": "UTC" + } +}, +{ + "model": "accounts.user", + "pk": 2, + "fields": { + "password": "pbkdf2_sha256$150000$5lBD7JemxYfE$B+lM5wWUW2n/ZulPFaWHtzWjyQ/QZ6iwjAC2I0R/VzU=", + "last_login": "2019-11-27T18:57:36.686Z", + "is_superuser": true, + "first_name": "", + "last_name": "", + "is_staff": true, + "is_active": true, + "date_joined": "2019-07-18T18:52:36.080Z", + "email": "sonny@bakker.nl", + "task": 10, + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "accounts.user", + "pk": 18, + "fields": { + "password": "pbkdf2_sha256$150000$vUwxT8T25R8C$S+Eq2tMRbSDE31/X5KGJ/M+Nblh7kKfzuM/z7HraR/Q=", + "last_login": null, + "is_superuser": false, + "first_name": "", + "last_name": "", + "is_staff": false, + "is_active": false, + "date_joined": "2019-11-25T15:35:14.051Z", + "email": "sonnyba871@gmail.com", + "task": 26, + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "core.category", + "pk": 8, + "fields": { + "created": "2019-11-17T19:37:24.671Z", + "modified": "2019-11-18T19:59:55.010Z", + "name": "World news", + "user": 2 + } +}, +{ + "model": "core.category", + "pk": 9, + "fields": { + "created": "2019-11-17T19:37:26.161Z", + "modified": "2019-11-18T19:59:45.010Z", + "name": "Tech", + "user": 2 + } +}, +{ + "model": "collection.collectionrule", + "pk": 3, + "fields": { + "created": "2019-07-14T13:08:10.374Z", + "modified": "2019-11-29T22:35:20.346Z", + "name": "Hackers News", + "url": "https://news.ycombinator.com/rss", + "website_url": "https://news.ycombinator.com/", + "favicon": "https://news.ycombinator.com/favicon.ico", + "timezone": "UTC", + "category": 9, + "last_suceeded": "2019-11-29T22:35:20.235Z", + "succeeded": true, + "error": null, + "user": 2 + } +}, +{ + "model": "collection.collectionrule", + "pk": 4, + "fields": { + "created": "2019-07-20T11:24:32.745Z", + "modified": "2019-11-29T22:35:19.525Z", + "name": "BBC", + "url": "http://feeds.bbci.co.uk/news/world/rss.xml", + "website_url": "https://www.bbc.co.uk/news/", + "favicon": "https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png", + "timezone": "UTC", + "category": 8, + "last_suceeded": "2019-11-29T22:35:19.241Z", + "succeeded": true, + "error": null, + "user": 2 + } +}, +{ + "model": "collection.collectionrule", + "pk": 5, + "fields": { + "created": "2019-07-20T11:24:50.411Z", + "modified": "2019-11-29T22:35:20.010Z", + "name": "Ars Technica", + "url": "http://feeds.arstechnica.com/arstechnica/index?fmt=xml", + "website_url": "https://arstechnica.com", + "favicon": "https://cdn.arstechnica.net/favicon.ico", + "timezone": "UTC", + "category": 9, + "last_suceeded": "2019-11-29T22:35:19.808Z", + "succeeded": true, + "error": null, + "user": 2 + } +}, +{ + "model": "collection.collectionrule", + "pk": 6, + "fields": { + "created": "2019-07-20T11:25:02.089Z", + "modified": "2019-11-29T22:35:20.233Z", + "name": "The Guardian", + "url": "https://www.theguardian.com/world/rss", + "website_url": "https://www.theguardian.com/world", + "favicon": "https://assets.guim.co.uk/images/favicons/873381bf11d58e20f551905d51575117/72x72.png", + "timezone": "UTC", + "category": 8, + "last_suceeded": "2019-11-29T22:35:20.076Z", + "succeeded": true, + "error": null, + "user": 2 + } +}, +{ + "model": "collection.collectionrule", + "pk": 7, + "fields": { + "created": "2019-07-20T11:25:30.121Z", + "modified": "2019-11-29T22:35:19.695Z", + "name": "Tweakers", + "url": "http://feeds.feedburner.com/tweakers/mixed?fmt=xml", + "website_url": "https://tweakers.net/", + "favicon": null, + "timezone": "UTC", + "category": 9, + "last_suceeded": "2019-11-29T22:35:19.528Z", + "succeeded": true, + "error": null, + "user": 2 + } +}, +{ + "model": "collection.collectionrule", + "pk": 8, + "fields": { + "created": "2019-07-20T11:25:46.256Z", + "modified": "2019-11-29T22:35:20.074Z", + "name": "The Verge", + "url": "https://www.theverge.com/rss/index.xml", + "website_url": "https://www.theverge.com/", + "favicon": "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395367/favicon-16x16.0.png", + "timezone": "UTC", + "category": 9, + "last_suceeded": "2019-11-29T22:35:20.012Z", + "succeeded": true, + "error": null, + "user": 2 + } +}, +{ + "model": "collection.collectionrule", + "pk": 9, + "fields": { + "created": "2019-11-24T15:28:41.399Z", + "modified": "2019-11-29T22:35:19.807Z", + "name": "NOS", + "url": "http://feeds.nos.nl/nosnieuwsalgemeen", + "website_url": null, + "favicon": null, + "timezone": "Europe/Amsterdam", + "category": 8, + "last_suceeded": "2019-11-29T22:35:19.697Z", + "succeeded": true, + "error": null, + "user": 2 + } +} ] From d345bc2595dba8d1e6596986c2d7e9dac807afb4 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sat, 30 Nov 2019 16:27:50 +0100 Subject: [PATCH 030/422] Add missing model to fixture --- src/newsreader/fixtures/default-fixture.json | 70 +++++++++++++++++--- 1 file changed, 59 insertions(+), 11 deletions(-) diff --git a/src/newsreader/fixtures/default-fixture.json b/src/newsreader/fixtures/default-fixture.json index 7639b81..ed3c3be 100644 --- a/src/newsreader/fixtures/default-fixture.json +++ b/src/newsreader/fixtures/default-fixture.json @@ -93,8 +93,39 @@ } }, { - "model": "accounts.user", + "model": "django_celery_beat.intervalschedule", + "pk": 1, + "fields": { + "every": 5, + "period": "minutes" + } +}, +{ + "model": "django_celery_beat.intervalschedule", "pk": 2, + "fields": { + "every": 15, + "period": "minutes" + } +}, +{ + "model": "django_celery_beat.intervalschedule", + "pk": 3, + "fields": { + "every": 30, + "period": "minutes" + } +}, +{ + "model": "django_celery_beat.intervalschedule", + "pk": 4, + "fields": { + "every": 1, + "period": "hours" + } +}, +{ + "model": "accounts.user", "fields": { "password": "pbkdf2_sha256$150000$5lBD7JemxYfE$B+lM5wWUW2n/ZulPFaWHtzWjyQ/QZ6iwjAC2I0R/VzU=", "last_login": "2019-11-27T18:57:36.686Z", @@ -112,7 +143,6 @@ }, { "model": "accounts.user", - "pk": 18, "fields": { "password": "pbkdf2_sha256$150000$vUwxT8T25R8C$S+Eq2tMRbSDE31/X5KGJ/M+Nblh7kKfzuM/z7HraR/Q=", "last_login": null, @@ -135,7 +165,9 @@ "created": "2019-11-17T19:37:24.671Z", "modified": "2019-11-18T19:59:55.010Z", "name": "World news", - "user": 2 + "user": [ + "sonny@bakker.nl" + ] } }, { @@ -145,7 +177,9 @@ "created": "2019-11-17T19:37:26.161Z", "modified": "2019-11-18T19:59:45.010Z", "name": "Tech", - "user": 2 + "user": [ + "sonny@bakker.nl" + ] } }, { @@ -163,7 +197,9 @@ "last_suceeded": "2019-11-29T22:35:20.235Z", "succeeded": true, "error": null, - "user": 2 + "user": [ + "sonny@bakker.nl" + ] } }, { @@ -181,7 +217,9 @@ "last_suceeded": "2019-11-29T22:35:19.241Z", "succeeded": true, "error": null, - "user": 2 + "user": [ + "sonny@bakker.nl" + ] } }, { @@ -199,7 +237,9 @@ "last_suceeded": "2019-11-29T22:35:19.808Z", "succeeded": true, "error": null, - "user": 2 + "user": [ + "sonny@bakker.nl" + ] } }, { @@ -217,7 +257,9 @@ "last_suceeded": "2019-11-29T22:35:20.076Z", "succeeded": true, "error": null, - "user": 2 + "user": [ + "sonny@bakker.nl" + ] } }, { @@ -235,7 +277,9 @@ "last_suceeded": "2019-11-29T22:35:19.528Z", "succeeded": true, "error": null, - "user": 2 + "user": [ + "sonny@bakker.nl" + ] } }, { @@ -253,7 +297,9 @@ "last_suceeded": "2019-11-29T22:35:20.012Z", "succeeded": true, "error": null, - "user": 2 + "user": [ + "sonny@bakker.nl" + ] } }, { @@ -271,7 +317,9 @@ "last_suceeded": "2019-11-29T22:35:19.697Z", "succeeded": true, "error": null, - "user": 2 + "user": [ + "sonny@bakker.nl" + ] } } ] From 38d9d74db416cfe3fc8cf559b8f21ef666acd692 Mon Sep 17 00:00:00 2001 From: sonny Date: Tue, 31 Dec 2019 14:32:29 +0100 Subject: [PATCH 031/422] Collection rule pages --- gulp/babel.js | 8 + gulp/sass.js | 9 ++ src/newsreader/js/components/Messages.js | 6 +- .../categories/components/CategoryCard.js | 4 +- src/newsreader/js/pages/rules/App.js | 100 ++++++++++++ .../js/pages/rules/components/RuleCard.js | 62 ++++++++ .../js/pages/rules/components/RuleModal.js | 33 ++++ src/newsreader/js/pages/rules/index.js | 9 ++ src/newsreader/news/collection/forms.py | 30 ++++ .../templates/collection/rule-create.html | 31 ++++ .../templates/collection/rule-update.html | 34 ++++ .../collection/templates/collection/rule.html | 70 +++++++++ .../templates/collection/rules.html | 33 ++++ .../news/collection/tests/test_views.py | 148 ++++++++++++++++++ src/newsreader/news/collection/urls.py | 22 ++- src/newsreader/news/collection/views.py | 58 +++++++ src/newsreader/news/core/forms.py | 6 +- .../scss/components/card/_card.scss | 1 + src/newsreader/scss/components/errorlist/; | 3 - .../scss/pages/rule/components/index.scss | 1 + .../rule/components/rule-form/_rule-form.scss | 27 ++++ .../rule/components/rule-form/index.scss | 1 + .../scss/pages/rule/elements/index.scss | 0 src/newsreader/scss/pages/rule/index.scss | 8 + .../pages/rules/components/card/_card.scss | 20 +++ .../pages/rules/components/card/index.scss | 1 + .../scss/pages/rules/components/index.scss | 3 + .../components/rule-modal/_rule-modal.scss | 27 ++++ .../rules/components/rule-modal/index.scss | 1 + .../pages/rules/components/rules/_rules.scss | 7 + .../pages/rules/components/rules/index.scss | 1 + .../scss/pages/rules/elements/index.scss | 0 src/newsreader/scss/pages/rules/index.scss | 8 + src/newsreader/templates/base.html | 2 +- src/newsreader/urls.py | 6 +- 35 files changed, 766 insertions(+), 14 deletions(-) create mode 100644 src/newsreader/js/pages/rules/App.js create mode 100644 src/newsreader/js/pages/rules/components/RuleCard.js create mode 100644 src/newsreader/js/pages/rules/components/RuleModal.js create mode 100644 src/newsreader/js/pages/rules/index.js create mode 100644 src/newsreader/news/collection/forms.py create mode 100644 src/newsreader/news/collection/templates/collection/rule-create.html create mode 100644 src/newsreader/news/collection/templates/collection/rule-update.html create mode 100644 src/newsreader/news/collection/templates/collection/rule.html create mode 100644 src/newsreader/news/collection/templates/collection/rules.html create mode 100644 src/newsreader/news/collection/tests/test_views.py delete mode 100644 src/newsreader/scss/components/errorlist/; create mode 100644 src/newsreader/scss/pages/rule/components/index.scss create mode 100644 src/newsreader/scss/pages/rule/components/rule-form/_rule-form.scss create mode 100644 src/newsreader/scss/pages/rule/components/rule-form/index.scss create mode 100644 src/newsreader/scss/pages/rule/elements/index.scss create mode 100644 src/newsreader/scss/pages/rule/index.scss create mode 100644 src/newsreader/scss/pages/rules/components/card/_card.scss create mode 100644 src/newsreader/scss/pages/rules/components/card/index.scss create mode 100644 src/newsreader/scss/pages/rules/components/index.scss create mode 100644 src/newsreader/scss/pages/rules/components/rule-modal/_rule-modal.scss create mode 100644 src/newsreader/scss/pages/rules/components/rule-modal/index.scss create mode 100644 src/newsreader/scss/pages/rules/components/rules/_rules.scss create mode 100644 src/newsreader/scss/pages/rules/components/rules/index.scss create mode 100644 src/newsreader/scss/pages/rules/elements/index.scss create mode 100644 src/newsreader/scss/pages/rules/index.scss diff --git a/gulp/babel.js b/gulp/babel.js index 3c33996..3ef77d3 100644 --- a/gulp/babel.js +++ b/gulp/babel.js @@ -12,10 +12,18 @@ const SRC_DIR = path.join(PROJECT_DIR, 'js'); const STATIC_SUFFIX = 'dist/js/'; const CORE_DIR = path.join(PROJECT_DIR, 'news', 'core', 'static', 'core'); +const COLLECTION_DIR = path.join( + PROJECT_DIR, + 'news', + 'collection', + 'static', + 'collection' +); const taskMappings = [ { name: 'homepage', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` }, { name: 'categories', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` }, + { name: 'rules', destDir: `${COLLECTION_DIR}/${STATIC_SUFFIX}` }, ]; const babelTask = done => { diff --git a/gulp/sass.js b/gulp/sass.js index 2a71455..46cd291 100644 --- a/gulp/sass.js +++ b/gulp/sass.js @@ -10,6 +10,13 @@ const STATIC_SUFFIX = 'dist/css/'; export const ACCOUNTS_DIR = path.join(PROJECT_DIR, 'accounts', 'static', 'accounts'); export const CORE_DIR = path.join(PROJECT_DIR, 'news', 'core', 'static', 'core'); +export const COLLECTION_DIR = path.join( + PROJECT_DIR, + 'news', + 'collection', + 'static', + 'collection' +); const taskMappings = [ { name: 'login', destDir: `${ACCOUNTS_DIR}/${STATIC_SUFFIX}` }, @@ -19,6 +26,8 @@ const taskMappings = [ { name: 'homepage', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` }, { name: 'categories', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` }, { name: 'category', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` }, + { name: 'rules', destDir: `${COLLECTION_DIR}/${STATIC_SUFFIX}` }, + { name: 'rule', destDir: `${COLLECTION_DIR}/${STATIC_SUFFIX}` }, ]; export const sassTask = done => { diff --git a/src/newsreader/js/components/Messages.js b/src/newsreader/js/components/Messages.js index fe8cb20..a985381 100644 --- a/src/newsreader/js/components/Messages.js +++ b/src/newsreader/js/components/Messages.js @@ -1,9 +1,11 @@ import React from 'react'; const Messages = props => { - const messages = props.messages.map(message => { + const messages = props.messages.map((index, message) => { return ( -
  • {message.text}
  • +
  • + {message.text} +
  • ); }); diff --git a/src/newsreader/js/pages/categories/components/CategoryCard.js b/src/newsreader/js/pages/categories/components/CategoryCard.js index 04fccef..5c58a51 100644 --- a/src/newsreader/js/pages/categories/components/CategoryCard.js +++ b/src/newsreader/js/pages/categories/components/CategoryCard.js @@ -19,9 +19,7 @@ const CategoryCard = props => { const cardHeader = ( <>

    {category.name}

    - - {category.created} - + {category.created} ); const cardContent = <>{category.rules &&
      {categoryRules}
    }; diff --git a/src/newsreader/js/pages/rules/App.js b/src/newsreader/js/pages/rules/App.js new file mode 100644 index 0000000..fd01229 --- /dev/null +++ b/src/newsreader/js/pages/rules/App.js @@ -0,0 +1,100 @@ +import React from 'react'; + +import Cookies from 'js-cookie'; + +import Card from '../../components/Card.js'; +import RuleCard from './components/RuleCard.js'; +import RuleModal from './components/RuleModal.js'; +import Messages from '../../components/Messages.js'; + +class App extends React.Component { + selectRule = ::this.selectRule; + deselectRule = ::this.deselectRule; + deleteRule = ::this.deleteRule; + + constructor(props) { + super(props); + + this.token = Cookies.get('csrftoken'); + this.state = { + rules: props.rules, + selectedRuleId: null, + message: null, + }; + } + + selectRule(ruleId) { + this.setState({ selectedRuleId: ruleId }); + } + + deselectRule() { + this.setState({ selectedRuleId: null }); + } + + deleteRule(ruleId) { + const url = `/api/rules/${ruleId}/`; + const options = { + method: 'DELETE', + headers: { + 'X-CSRFToken': this.token, + }, + }; + + fetch(url, options).then(response => { + if (response.ok) { + const rules = this.state.rules.filter(rule => { + return rule.pk != ruleId; + }); + + return this.setState({ + rules: rules, + selectedRuleId: null, + message: null, + }); + } + }); + + const message = { + type: 'error', + text: 'Unable to remove rule, try again later', + }; + return this.setState({ selectedRuleId: null, message: message }); + } + + render() { + const { rules } = this.state; + const cards = rules.map(rule => { + return ; + }); + + const selectedRule = rules.find(rule => { + return rule.pk === this.state.selectedRuleId; + }); + + const pageHeader = ( + <> +

    Rules

    + + Create rule + + + ); + + return ( + <> + {this.state.message && } + + {cards} + {selectedRule && ( + + )} + + ); + } +} + +export default App; diff --git a/src/newsreader/js/pages/rules/components/RuleCard.js b/src/newsreader/js/pages/rules/components/RuleCard.js new file mode 100644 index 0000000..c94144e --- /dev/null +++ b/src/newsreader/js/pages/rules/components/RuleCard.js @@ -0,0 +1,62 @@ +import React from 'react'; + +import Card from '../../../components/Card.js'; + +const RuleCard = props => { + const { rule } = props; + + const faviconUrl = rule.favicon ? rule.favicon : '/static/picture.svg'; + const stateIcon = rule.succeeded + ? '/static/checkmark-circle.svg' + : '/static/warning.svg'; + + const cardHeader = ( + <> +
    + +

    {rule.name}

    +
    + + + ); + + const cardContent = ( + <> + + {!rule.succeeded && ( +
      +
    • {rule.error}
    • +
    + )} + + ); + + const cardFooter = ( + <> + + Edit + + + + ); + + return ; +}; + +export default RuleCard; diff --git a/src/newsreader/js/pages/rules/components/RuleModal.js b/src/newsreader/js/pages/rules/components/RuleModal.js new file mode 100644 index 0000000..5d6f09b --- /dev/null +++ b/src/newsreader/js/pages/rules/components/RuleModal.js @@ -0,0 +1,33 @@ +import React from 'react'; + +import Modal from '../../../components/Modal.js'; + +const RuleModal = props => { + const content = ( +
    +
    +

    Delete rule

    +
    + +
    +

    Are you sure you want to delete {props.rule.name}?

    +
    + +
    + + +
    +
    + ); + + return ; +}; + +export default RuleModal; diff --git a/src/newsreader/js/pages/rules/index.js b/src/newsreader/js/pages/rules/index.js new file mode 100644 index 0000000..7fc7a5c --- /dev/null +++ b/src/newsreader/js/pages/rules/index.js @@ -0,0 +1,9 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; + +import App from './App.js'; + +const dataScript = document.getElementById('rules-data'); +const rules = JSON.parse(dataScript.textContent); + +ReactDOM.render(, document.getElementsByClassName('content')[0]); diff --git a/src/newsreader/news/collection/forms.py b/src/newsreader/news/collection/forms.py new file mode 100644 index 0000000..89c72e9 --- /dev/null +++ b/src/newsreader/news/collection/forms.py @@ -0,0 +1,30 @@ +from django import forms + +from newsreader.news.collection.models import CollectionRule +from newsreader.news.core.models import Category + + +class CollectionRuleForm(forms.ModelForm): + category = forms.ModelChoiceField(required=False, queryset=Category.objects.all()) + + def __init__(self, *args, **kwargs) -> None: + self.user = kwargs.pop("user") + + super().__init__(*args, **kwargs) + + if self.user: + self.fields["category"].queryset = Category.objects.filter(user=self.user) + + def save(self, commit=True) -> CollectionRule: + instance = super().save(commit=False) + instance.user = self.user + + if commit: + instance.save() + self.save_m2m() + + return instance + + class Meta: + model = CollectionRule + fields = ("name", "url", "timezone", "favicon", "category") diff --git a/src/newsreader/news/collection/templates/collection/rule-create.html b/src/newsreader/news/collection/templates/collection/rule-create.html new file mode 100644 index 0000000..4bb242a --- /dev/null +++ b/src/newsreader/news/collection/templates/collection/rule-create.html @@ -0,0 +1,31 @@ +{% extends "collection/rule.html" %} + +{% block form-header %} +

    Create a rule

    +{% endblock %} + +{% block name-input %} + +{% endblock %} + +{% block category-input %} + +{% endblock %} + +{% block url-input %} + +{% endblock %} + +{% block favicon-input %} + +{% endblock %} + +{% block timezone-input %} + +{% endblock %} + +{% block confirm-button %} + +{% endblock %} diff --git a/src/newsreader/news/collection/templates/collection/rule-update.html b/src/newsreader/news/collection/templates/collection/rule-update.html new file mode 100644 index 0000000..7c0a4ba --- /dev/null +++ b/src/newsreader/news/collection/templates/collection/rule-update.html @@ -0,0 +1,34 @@ +{% extends "collection/rule.html" %} + +{% block form-header %} +

    Update rule

    +{% endblock %} + +{% block name-input %} + +{% endblock %} + +{% block category-input %} + +{% endblock %} + +{% block url-input %} + +{% endblock %} + +{% block favicon-input %} + +{% endblock %} + +{% block timezone-input %} + +{% endblock %} + +{% block confirm-button %} + +{% endblock %} diff --git a/src/newsreader/news/collection/templates/collection/rule.html b/src/newsreader/news/collection/templates/collection/rule.html new file mode 100644 index 0000000..0d67e08 --- /dev/null +++ b/src/newsreader/news/collection/templates/collection/rule.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} + +{% load static %} + +{% block head %} + +{% endblock %} + +{% block content %} +
    +
    + {% csrf_token %} + {{ form.non_field_errors }} + +
    + {% block form-header %}{% endblock %} +
    +
    +
    + + {% block name-input %}{% endblock %} + {{ form.name.errors }} +
    + +
    + + + + + {{ form.category.errors }} +
    + +
    + + {% block url-input %}{% endblock %} + {{ form.url.errors }} +
    + +
    + + {% block favicon-input %}{% endblock %} + {{ form.favicon.errors }} +
    + +
    + + The timezone which the feed uses + + + {{ form.timezone.errors }} +
    +
    + +
    +
    + Cancel + {% block confirm-button %}{% endblock %} +
    +
    +
    +
    +{% endblock %} diff --git a/src/newsreader/news/collection/templates/collection/rules.html b/src/newsreader/news/collection/templates/collection/rules.html new file mode 100644 index 0000000..844e27b --- /dev/null +++ b/src/newsreader/news/collection/templates/collection/rules.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} + +{% load static %} + +{% block head %} + +{% endblock %} + +{% block content %} +
    +{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/src/newsreader/news/collection/tests/test_views.py b/src/newsreader/news/collection/tests/test_views.py new file mode 100644 index 0000000..d6a478e --- /dev/null +++ b/src/newsreader/news/collection/tests/test_views.py @@ -0,0 +1,148 @@ +from django.test import Client, TestCase +from django.urls import reverse + +import pytz + +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.core.tests.factories import CategoryFactory + + +class CollectionRuleViewTestCase: + def setUp(self): + self.user = UserFactory(password="test") + self.client.login(email=self.user.email, password="test") + + self.category = CategoryFactory(user=self.user) + self.form_data = {"name": "", "category": "", "url": "", "timezone": ""} + + def test_simple(self): + response = self.client.get(self.url) + + self.assertEquals(response.status_code, 200) + + def test_no_category(self): + self.form_data.update(category="") + + response = self.client.post(self.url, self.form_data) + self.assertEquals(response.status_code, 302) + + rule = CollectionRule.objects.get() + + self.assertEquals(rule.category, None) + + def test_categories_only_from_user(self): + other_user = UserFactory() + other_categories = CategoryFactory.create_batch(size=4, user=other_user) + + response = self.client.get(self.url) + + for category in other_categories: + self.assertNotContains(response, category.name) + + def test_category_of_other_user(self): + other_user = UserFactory() + other_rule = CollectionRuleFactory(name="other rule", user=other_user) + + self.form_data.update( + name="new name", + category=other_rule.category, + url=other_rule.url, + timezone=other_rule.timezone, + ) + + other_url = reverse("rule-update", args=[other_rule.pk]) + response = self.client.post(other_url, self.form_data) + + self.assertEquals(response.status_code, 404) + + other_rule.refresh_from_db() + + self.assertEquals(other_rule.name, "other rule") + + def test_with_other_user_rules(self): + other_user = UserFactory() + other_categories = CategoryFactory.create_batch(size=4, user=other_user) + + self.form_data.update(category=other_categories[2].pk) + + response = self.client.post(self.url, self.form_data) + + self.assertContains(response, "not one of the available choices") + + +class CollectionRuleCreateViewTestCase(CollectionRuleViewTestCase, TestCase): + def setUp(self): + super().setUp() + + self.url = reverse("rule-create") + + self.form_data.update( + name="new rule", + url="https://www.rss.com/rss", + timezone=pytz.utc, + category=str(self.category.pk), + ) + + def test_creation(self): + response = self.client.post(self.url, self.form_data) + + self.assertEquals(response.status_code, 302) + + rule = CollectionRule.objects.get(name="new rule") + + self.assertEquals(rule.url, "https://www.rss.com/rss") + self.assertEquals(rule.timezone, str(pytz.utc)) + self.assertEquals(rule.favicon, None) + self.assertEquals(rule.category.pk, self.category.pk) + self.assertEquals(rule.user.pk, self.user.pk) + + +class CollectionRuleUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): + def setUp(self): + super().setUp() + + self.rule = CollectionRuleFactory( + name="collection rule", user=self.user, category=self.category + ) + self.url = reverse("rule-update", args=[self.rule.pk]) + + self.form_data.update( + name=self.rule.name, + category=self.rule.category.pk, + url=self.rule.url, + timezone=self.rule.timezone, + ) + + def test_name_change(self): + self.form_data.update(name="new name") + + response = self.client.post(self.url, self.form_data) + self.assertEquals(response.status_code, 302) + + self.rule.refresh_from_db() + + self.assertEquals(self.rule.name, "new name") + + def test_category_change(self): + new_category = CategoryFactory(user=self.user) + + self.form_data.update(category=new_category.pk) + + response = self.client.post(self.url, self.form_data) + self.assertEquals(response.status_code, 302) + + self.rule.refresh_from_db() + + self.assertEquals(self.rule.category.pk, new_category.pk) + + def test_category_removal(self): + self.form_data.update(category="") + + response = self.client.post(self.url, self.form_data) + self.assertEquals(response.status_code, 302) + + self.rule.refresh_from_db() + + self.assertEquals(self.rule.category, None) diff --git a/src/newsreader/news/collection/urls.py b/src/newsreader/news/collection/urls.py index 606ec3a..16c80b7 100644 --- a/src/newsreader/news/collection/urls.py +++ b/src/newsreader/news/collection/urls.py @@ -1,3 +1,4 @@ +from django.contrib.auth.decorators import login_required from django.urls import path from newsreader.news.collection.endpoints import ( @@ -6,11 +7,30 @@ from newsreader.news.collection.endpoints import ( NestedRuleView, RuleReadView, ) +from newsreader.news.collection.views import ( + CollectionRuleCreateView, + CollectionRuleListView, + CollectionRuleUpdateView, +) endpoints = [ - path("rules/", DetailRuleView.as_view(), name="rules-detail"), + path("rules//", DetailRuleView.as_view(), name="rules-detail"), path("rules//posts/", NestedRuleView.as_view(), name="rules-nested-posts"), path("rules//read/", RuleReadView.as_view(), name="rules-read"), path("rules/", ListRuleView.as_view(), name="rules-list"), ] + +urlpatterns = [ + path("rules/", login_required(CollectionRuleListView.as_view()), name="rules"), + path( + "rules//", + login_required(CollectionRuleUpdateView.as_view()), + name="rule-update", + ), + path( + "rules/create/", + login_required(CollectionRuleCreateView.as_view()), + name="rule-create", + ), +] diff --git a/src/newsreader/news/collection/views.py b/src/newsreader/news/collection/views.py index e69de29..8919ddf 100644 --- a/src/newsreader/news/collection/views.py +++ b/src/newsreader/news/collection/views.py @@ -0,0 +1,58 @@ +from typing import Dict, Iterable + +from django.urls import reverse_lazy +from django.views.generic.edit import CreateView, UpdateView +from django.views.generic.list import ListView + +import pytz + +from newsreader.news.collection.forms import CollectionRuleForm +from newsreader.news.collection.models import CollectionRule +from newsreader.news.core.models import Category + + +class CollectionRuleViewMixin: + queryset = CollectionRule.objects.order_by("name") + + def get_queryset(self) -> Iterable: + user = self.request.user + return self.queryset.filter(user=user) + + +class CollectionRuleDetailMixin: + success_url = reverse_lazy("rules") + form_class = CollectionRuleForm + + def get_context_data(self, **kwargs) -> Dict: + context_data = super().get_context_data(**kwargs) + + rules = Category.objects.filter(user=self.request.user).order_by("name") + timezones = [timezone for timezone in pytz.all_timezones] + + context_data["categories"] = rules + context_data["timezones"] = timezones + + return context_data + + def get_form_kwargs(self) -> Dict: + kwargs = super().get_form_kwargs() + kwargs["user"] = self.request.user + return kwargs + + +class CollectionRuleListView(CollectionRuleViewMixin, ListView): + template_name = "collection/rules.html" + context_object_name = "rules" + + +class CollectionRuleUpdateView( + CollectionRuleViewMixin, CollectionRuleDetailMixin, UpdateView +): + template_name = "collection/rule-update.html" + context_object_name = "rule" + + +class CollectionRuleCreateView( + CollectionRuleViewMixin, CollectionRuleDetailMixin, CreateView +): + template_name = "collection/rule-create.html" diff --git a/src/newsreader/news/core/forms.py b/src/newsreader/news/core/forms.py index 38e03f4..8fa2221 100644 --- a/src/newsreader/news/core/forms.py +++ b/src/newsreader/news/core/forms.py @@ -6,8 +6,8 @@ from newsreader.news.core.models import Category class CategoryForm(forms.ModelForm): rules = forms.ModelMultipleChoiceField( - queryset=CollectionRule.objects.all(), required=False, + queryset=CollectionRule.objects.all(), widget=forms.widgets.CheckboxSelectMultiple, ) @@ -17,7 +17,9 @@ class CategoryForm(forms.ModelForm): super().__init__(*args, **kwargs) if self.user: - self.fields["rules"].queryset = CollectionRule.objects.filter(user=self.user) + self.fields["rules"].queryset = CollectionRule.objects.filter( + user=self.user + ) def save(self, commit=True) -> Category: instance = super().save(commit=False) diff --git a/src/newsreader/scss/components/card/_card.scss b/src/newsreader/scss/components/card/_card.scss index d979eeb..23a8079 100644 --- a/src/newsreader/scss/components/card/_card.scss +++ b/src/newsreader/scss/components/card/_card.scss @@ -13,6 +13,7 @@ &__header { display: flex; justify-content: space-between; + align-items: center; padding: 15px 0; diff --git a/src/newsreader/scss/components/errorlist/; b/src/newsreader/scss/components/errorlist/; deleted file mode 100644 index efcee40..0000000 --- a/src/newsreader/scss/components/errorlist/; +++ /dev/null @@ -1,3 +0,0 @@ -.errorlist { - -} diff --git a/src/newsreader/scss/pages/rule/components/index.scss b/src/newsreader/scss/pages/rule/components/index.scss new file mode 100644 index 0000000..de2a031 --- /dev/null +++ b/src/newsreader/scss/pages/rule/components/index.scss @@ -0,0 +1 @@ +@import "rule-form/index"; diff --git a/src/newsreader/scss/pages/rule/components/rule-form/_rule-form.scss b/src/newsreader/scss/pages/rule/components/rule-form/_rule-form.scss new file mode 100644 index 0000000..95a2388 --- /dev/null +++ b/src/newsreader/scss/pages/rule/components/rule-form/_rule-form.scss @@ -0,0 +1,27 @@ +.rule-form { + margin: 20px 0; + + &__section:last-child { + + & .rule-form__fieldset { + display: flex; + flex-direction: row; + justify-content: space-between; + } + } + + &__select[name=category] { + width: 50%; + + padding: 0 10px; + } + + &__select[name=timezone] { + max-height: 200px; + width: 50%; + + margin: 0 15px; + padding: 0 10px; + + } +} diff --git a/src/newsreader/scss/pages/rule/components/rule-form/index.scss b/src/newsreader/scss/pages/rule/components/rule-form/index.scss new file mode 100644 index 0000000..4c7fbee --- /dev/null +++ b/src/newsreader/scss/pages/rule/components/rule-form/index.scss @@ -0,0 +1 @@ +@import "rule-form"; diff --git a/src/newsreader/scss/pages/rule/elements/index.scss b/src/newsreader/scss/pages/rule/elements/index.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/scss/pages/rule/index.scss b/src/newsreader/scss/pages/rule/index.scss new file mode 100644 index 0000000..16b6493 --- /dev/null +++ b/src/newsreader/scss/pages/rule/index.scss @@ -0,0 +1,8 @@ +// General imports +@import "../../partials/variables"; +@import "../../components/index"; +@import "../../elements/index"; + +// Page specific +@import "./components/index"; +@import "./elements/index"; diff --git a/src/newsreader/scss/pages/rules/components/card/_card.scss b/src/newsreader/scss/pages/rules/components/card/_card.scss new file mode 100644 index 0000000..ec09189 --- /dev/null +++ b/src/newsreader/scss/pages/rules/components/card/_card.scss @@ -0,0 +1,20 @@ +.card { + &__header { + & div { + display: flex; + flex-direction: row; + + & img { + padding: 0 10px; + } + } + } + + &__content { + flex-direction: column; + } + + &__footer > *:last-child { + margin: 0 0 0 10px; + } +} diff --git a/src/newsreader/scss/pages/rules/components/card/index.scss b/src/newsreader/scss/pages/rules/components/card/index.scss new file mode 100644 index 0000000..484e154 --- /dev/null +++ b/src/newsreader/scss/pages/rules/components/card/index.scss @@ -0,0 +1 @@ +@import "card"; diff --git a/src/newsreader/scss/pages/rules/components/index.scss b/src/newsreader/scss/pages/rules/components/index.scss new file mode 100644 index 0000000..7e96fec --- /dev/null +++ b/src/newsreader/scss/pages/rules/components/index.scss @@ -0,0 +1,3 @@ +@import "card/index"; +@import "rules/index"; +@import "rule-modal/index"; diff --git a/src/newsreader/scss/pages/rules/components/rule-modal/_rule-modal.scss b/src/newsreader/scss/pages/rules/components/rule-modal/_rule-modal.scss new file mode 100644 index 0000000..0dc53bb --- /dev/null +++ b/src/newsreader/scss/pages/rules/components/rule-modal/_rule-modal.scss @@ -0,0 +1,27 @@ +.rule-modal { + display: flex; + flex-direction: column; + align-self: center; + + margin: 20px 0; + width: 50%; + + border-radius: 2px; + background-color: $white; + + &__header { + padding: 5px 20px; + } + + &__content { + padding: 10px 30px; + } + + &__footer { + display: flex; + flex-direction: row; + justify-content: space-between; + + padding: 10px; + } +} diff --git a/src/newsreader/scss/pages/rules/components/rule-modal/index.scss b/src/newsreader/scss/pages/rules/components/rule-modal/index.scss new file mode 100644 index 0000000..c19060f --- /dev/null +++ b/src/newsreader/scss/pages/rules/components/rule-modal/index.scss @@ -0,0 +1 @@ +@import "rule-modal"; diff --git a/src/newsreader/scss/pages/rules/components/rules/_rules.scss b/src/newsreader/scss/pages/rules/components/rules/_rules.scss new file mode 100644 index 0000000..3fd4c22 --- /dev/null +++ b/src/newsreader/scss/pages/rules/components/rules/_rules.scss @@ -0,0 +1,7 @@ +.rules { + &__item { + & > * { + margin: 0; + } + } +} diff --git a/src/newsreader/scss/pages/rules/components/rules/index.scss b/src/newsreader/scss/pages/rules/components/rules/index.scss new file mode 100644 index 0000000..e6a0ebf --- /dev/null +++ b/src/newsreader/scss/pages/rules/components/rules/index.scss @@ -0,0 +1 @@ +@import "rules"; diff --git a/src/newsreader/scss/pages/rules/elements/index.scss b/src/newsreader/scss/pages/rules/elements/index.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/scss/pages/rules/index.scss b/src/newsreader/scss/pages/rules/index.scss new file mode 100644 index 0000000..16b6493 --- /dev/null +++ b/src/newsreader/scss/pages/rules/index.scss @@ -0,0 +1,8 @@ +// General imports +@import "../../partials/variables"; +@import "../../components/index"; +@import "../../elements/index"; + +// Page specific +@import "./components/index"; +@import "./elements/index"; diff --git a/src/newsreader/templates/base.html b/src/newsreader/templates/base.html index 445d507..087aca7 100644 --- a/src/newsreader/templates/base.html +++ b/src/newsreader/templates/base.html @@ -11,7 +11,7 @@ {% if request.user.is_authenticated %} - + {% else %} diff --git a/src/newsreader/urls.py b/src/newsreader/urls.py index be59ad1..fb68235 100644 --- a/src/newsreader/urls.py +++ b/src/newsreader/urls.py @@ -6,8 +6,9 @@ from rest_framework_swagger.views import get_swagger_view from newsreader.accounts.urls import urlpatterns as login_urls from newsreader.news.collection.urls import endpoints as collection_endpoints +from newsreader.news.collection.urls import urlpatterns as collection_patterns from newsreader.news.core.urls import endpoints as core_endpoints -from newsreader.news.core.urls import urlpatterns +from newsreader.news.core.urls import urlpatterns as core_patterns schema_view = get_swagger_view(title="Newsreader API") @@ -18,7 +19,8 @@ endpoints = [ ] urlpatterns = [ - path("", include(urlpatterns)), + path("", include(core_patterns)), + path("", include(collection_patterns)), path("accounts/", include((login_urls, "accounts")), name="accounts"), path("admin/", admin.site.urls, name="admin"), path("api/", include((endpoints, "api")), name="api"), From 8acf3bbc7f1078cf7b4e56967d29d757cc4d6822 Mon Sep 17 00:00:00 2001 From: sonny Date: Tue, 31 Dec 2019 14:46:42 +0100 Subject: [PATCH 032/422] Rerun black --- src/newsreader/news/core/forms.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/newsreader/news/core/forms.py b/src/newsreader/news/core/forms.py index 8fa2221..5c14fab 100644 --- a/src/newsreader/news/core/forms.py +++ b/src/newsreader/news/core/forms.py @@ -17,9 +17,7 @@ class CategoryForm(forms.ModelForm): super().__init__(*args, **kwargs) if self.user: - self.fields["rules"].queryset = CollectionRule.objects.filter( - user=self.user - ) + self.fields["rules"].queryset = CollectionRule.objects.filter(user=self.user) def save(self, commit=True) -> Category: instance = super().save(commit=False) From 5a630ea4b3d5197b9eb289aea0184cdffa95f7b8 Mon Sep 17 00:00:00 2001 From: Sonny Date: Wed, 1 Jan 2020 16:38:10 +0100 Subject: [PATCH 033/422] Various changes - Update npm dependencies - Update default fixture to exclude default celery task --- package-lock.json | 1857 ++++++++---------- package.json | 38 +- src/newsreader/fixtures/default-fixture.json | 27 - 3 files changed, 792 insertions(+), 1130 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2b98192..a7433b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,36 +5,45 @@ "requires": true, "dependencies": { "@babel/code-frame": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.0.0.tgz", - "integrity": "sha512-OfC2uemaknXr87bdLUkWog7nYuliM9Ij5HUcajsVcMCpQrcLmtxRbVFTIqmcSkSeYRBFBRxs2FiUqFJDLdiebA==", + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.5.5.tgz", + "integrity": "sha512-27d4lZoomVyo51VegxI20xZPuSHusqbQag/ztrBC7wegWoQ1nLREPVSKSW8byhTlzTKyNE4ifaTA6lCp7JjpFw==", "dev": true, "requires": { "@babel/highlight": "^7.0.0" } }, "@babel/core": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.5.4.tgz", - "integrity": "sha512-+DaeBEpYq6b2+ZmHx3tHspC+ZRflrvLqwfv8E3hNr5LVQoyBnL8RPKSBCg+rK2W2My9PWlujBiqd0ZPsR9Q6zQ==", + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.7.7.tgz", + "integrity": "sha512-jlSjuj/7z138NLZALxVgrx13AOtqip42ATZP7+kYl53GvDV6+4dCek1mVUo8z8c8Xnw/mx2q3d9HWh3griuesQ==", "dev": true, "requires": { - "@babel/code-frame": "^7.0.0", - "@babel/generator": "^7.5.0", - "@babel/helpers": "^7.5.4", - "@babel/parser": "^7.5.0", - "@babel/template": "^7.4.4", - "@babel/traverse": "^7.5.0", - "@babel/types": "^7.5.0", - "convert-source-map": "^1.1.0", + "@babel/code-frame": "^7.5.5", + "@babel/generator": "^7.7.7", + "@babel/helpers": "^7.7.4", + "@babel/parser": "^7.7.7", + "@babel/template": "^7.7.4", + "@babel/traverse": "^7.7.4", + "@babel/types": "^7.7.4", + "convert-source-map": "^1.7.0", "debug": "^4.1.0", "json5": "^2.1.0", - "lodash": "^4.17.11", + "lodash": "^4.17.13", "resolve": "^1.3.2", "semver": "^5.4.1", "source-map": "^0.5.0" }, "dependencies": { + "convert-source-map": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", + "integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.1" + } + }, "debug": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", @@ -53,255 +62,170 @@ } }, "@babel/generator": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.5.0.tgz", - "integrity": "sha512-1TTVrt7J9rcG5PMjvO7VEG3FrEoEJNHxumRq66GemPmzboLWtIjjcJgk8rokuAS7IiRSpgVSu5Vb9lc99iJkOA==", + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.7.7.tgz", + "integrity": "sha512-/AOIBpHh/JU1l0ZFS4kiRCBnLi6OTHzh0RPk3h9isBxkkqELtQNFi1Vr/tiG9p1yfoUdKVwISuXWQR+hwwM4VQ==", "dev": true, "requires": { - "@babel/types": "^7.5.0", + "@babel/types": "^7.7.4", "jsesc": "^2.5.1", - "lodash": "^4.17.11", - "source-map": "^0.5.0", - "trim-right": "^1.0.1" + "lodash": "^4.17.13", + "source-map": "^0.5.0" } }, "@babel/helper-annotate-as-pure": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.0.0.tgz", - "integrity": "sha512-3UYcJUj9kvSLbLbUIfQTqzcy5VX7GRZ/CCDrnOaZorFFM01aXp1+GJwuFGV4NDDoAS+mOUyHcO6UD/RfqOks3Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.7.4.tgz", + "integrity": "sha512-2BQmQgECKzYKFPpiycoF9tlb5HA4lrVyAmLLVK177EcQAqjVLciUb2/R+n1boQ9y5ENV3uz2ZqiNw7QMBBw1Og==", "dev": true, "requires": { - "@babel/types": "^7.0.0" + "@babel/types": "^7.7.4" } }, "@babel/helper-builder-binary-assignment-operator-visitor": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.1.0.tgz", - "integrity": "sha512-qNSR4jrmJ8M1VMM9tibvyRAHXQs2PmaksQF7c1CGJNipfe3D8p+wgNwgso/P2A2r2mdgBWAXljNWR0QRZAMW8w==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.7.4.tgz", + "integrity": "sha512-Biq/d/WtvfftWZ9Uf39hbPBYDUo986m5Bb4zhkeYDGUllF43D+nUe5M6Vuo6/8JDK/0YX/uBdeoQpyaNhNugZQ==", "dev": true, "requires": { - "@babel/helper-explode-assignable-expression": "^7.1.0", - "@babel/types": "^7.0.0" + "@babel/helper-explode-assignable-expression": "^7.7.4", + "@babel/types": "^7.7.4" } }, "@babel/helper-builder-react-jsx": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/@babel/helper-builder-react-jsx/-/helper-builder-react-jsx-7.3.0.tgz", - "integrity": "sha512-MjA9KgwCuPEkQd9ncSXvSyJ5y+j2sICHyrI0M3L+6fnS4wMSNDc1ARXsbTfbb2cXHn17VisSnU/sHFTCxVxSMw==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-react-jsx/-/helper-builder-react-jsx-7.7.4.tgz", + "integrity": "sha512-kvbfHJNN9dg4rkEM4xn1s8d1/h6TYNvajy9L1wx4qLn9HFg0IkTsQi4rfBe92nxrPUFcMsHoMV+8rU7MJb3fCA==", "dev": true, "requires": { - "@babel/types": "^7.3.0", + "@babel/types": "^7.7.4", "esutils": "^2.0.0" } }, "@babel/helper-call-delegate": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@babel/helper-call-delegate/-/helper-call-delegate-7.4.4.tgz", - "integrity": "sha512-l79boDFJ8S1c5hvQvG+rc+wHw6IuH7YldmRKsYtpbawsxURu/paVy57FZMomGK22/JckepaikOkY0MoAmdyOlQ==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-call-delegate/-/helper-call-delegate-7.7.4.tgz", + "integrity": "sha512-8JH9/B7J7tCYJ2PpWVpw9JhPuEVHztagNVuQAFBVFYluRMlpG7F1CgKEgGeL6KFqcsIa92ZYVj6DSc0XwmN1ZA==", "dev": true, "requires": { - "@babel/helper-hoist-variables": "^7.4.4", - "@babel/traverse": "^7.4.4", - "@babel/types": "^7.4.4" + "@babel/helper-hoist-variables": "^7.7.4", + "@babel/traverse": "^7.7.4", + "@babel/types": "^7.7.4" } }, "@babel/helper-create-class-features-plugin": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.5.5.tgz", - "integrity": "sha512-ZsxkyYiRA7Bg+ZTRpPvB6AbOFKTFFK4LrvTet8lInm0V468MWCaSYJE+I7v2z2r8KNLtYiV+K5kTCnR7dvyZjg==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.7.4.tgz", + "integrity": "sha512-l+OnKACG4uiDHQ/aJT8dwpR+LhCJALxL0mJ6nzjB25e5IPwqV1VOsY7ah6UB1DG+VOXAIMtuC54rFJGiHkxjgA==", "dev": true, "requires": { - "@babel/helper-function-name": "^7.1.0", - "@babel/helper-member-expression-to-functions": "^7.5.5", - "@babel/helper-optimise-call-expression": "^7.0.0", + "@babel/helper-function-name": "^7.7.4", + "@babel/helper-member-expression-to-functions": "^7.7.4", + "@babel/helper-optimise-call-expression": "^7.7.4", "@babel/helper-plugin-utils": "^7.0.0", - "@babel/helper-replace-supers": "^7.5.5", - "@babel/helper-split-export-declaration": "^7.4.4" - }, - "dependencies": { - "@babel/code-frame": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.5.5.tgz", - "integrity": "sha512-27d4lZoomVyo51VegxI20xZPuSHusqbQag/ztrBC7wegWoQ1nLREPVSKSW8byhTlzTKyNE4ifaTA6lCp7JjpFw==", - "dev": true, - "requires": { - "@babel/highlight": "^7.0.0" - } - }, - "@babel/generator": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.5.5.tgz", - "integrity": "sha512-ETI/4vyTSxTzGnU2c49XHv2zhExkv9JHLTwDAFz85kmcwuShvYG2H08FwgIguQf4JC75CBnXAUM5PqeF4fj0nQ==", - "dev": true, - "requires": { - "@babel/types": "^7.5.5", - "jsesc": "^2.5.1", - "lodash": "^4.17.13", - "source-map": "^0.5.0", - "trim-right": "^1.0.1" - } - }, - "@babel/helper-member-expression-to-functions": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.5.5.tgz", - "integrity": "sha512-5qZ3D1uMclSNqYcXqiHoA0meVdv+xUEex9em2fqMnrk/scphGlGgg66zjMrPJESPwrFJ6sbfFQYUSa0Mz7FabA==", - "dev": true, - "requires": { - "@babel/types": "^7.5.5" - } - }, - "@babel/helper-replace-supers": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.5.5.tgz", - "integrity": "sha512-XvRFWrNnlsow2u7jXDuH4jDDctkxbS7gXssrP4q2nUD606ukXHRvydj346wmNg+zAgpFx4MWf4+usfC93bElJg==", - "dev": true, - "requires": { - "@babel/helper-member-expression-to-functions": "^7.5.5", - "@babel/helper-optimise-call-expression": "^7.0.0", - "@babel/traverse": "^7.5.5", - "@babel/types": "^7.5.5" - } - }, - "@babel/parser": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.5.5.tgz", - "integrity": "sha512-E5BN68cqR7dhKan1SfqgPGhQ178bkVKpXTPEXnFJBrEt8/DKRZlybmy+IgYLTeN7tp1R5Ccmbm2rBk17sHYU3g==", - "dev": true - }, - "@babel/traverse": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.5.5.tgz", - "integrity": "sha512-MqB0782whsfffYfSjH4TM+LMjrJnhCNEDMDIjeTpl+ASaUvxcjoiVCo/sM1GhS1pHOXYfWVCYneLjMckuUxDaQ==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.5.5", - "@babel/generator": "^7.5.5", - "@babel/helper-function-name": "^7.1.0", - "@babel/helper-split-export-declaration": "^7.4.4", - "@babel/parser": "^7.5.5", - "@babel/types": "^7.5.5", - "debug": "^4.1.0", - "globals": "^11.1.0", - "lodash": "^4.17.13" - } - }, - "@babel/types": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.5.5.tgz", - "integrity": "sha512-s63F9nJioLqOlW3UkyMd+BYhXt44YuaFm/VV0VwuteqjYwRrObkU7ra9pY4wAJR3oXi8hJrMcrcJdO/HH33vtw==", - "dev": true, - "requires": { - "esutils": "^2.0.2", - "lodash": "^4.17.13", - "to-fast-properties": "^2.0.0" - } - }, - "debug": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", - "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - } + "@babel/helper-replace-supers": "^7.7.4", + "@babel/helper-split-export-declaration": "^7.7.4" + } + }, + "@babel/helper-create-regexp-features-plugin": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.7.4.tgz", + "integrity": "sha512-Mt+jBKaxL0zfOIWrfQpnfYCN7/rS6GKx6CCCfuoqVVd+17R8zNDlzVYmIi9qyb2wOk002NsmSTDymkIygDUH7A==", + "dev": true, + "requires": { + "@babel/helper-regex": "^7.4.4", + "regexpu-core": "^4.6.0" } }, "@babel/helper-define-map": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@babel/helper-define-map/-/helper-define-map-7.4.4.tgz", - "integrity": "sha512-IX3Ln8gLhZpSuqHJSnTNBWGDE9kdkTEWl21A/K7PQ00tseBwbqCHTvNLHSBd9M0R5rER4h5Rsvj9vw0R5SieBg==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-define-map/-/helper-define-map-7.7.4.tgz", + "integrity": "sha512-v5LorqOa0nVQUvAUTUF3KPastvUt/HzByXNamKQ6RdJRTV7j8rLL+WB5C/MzzWAwOomxDhYFb1wLLxHqox86lg==", "dev": true, "requires": { - "@babel/helper-function-name": "^7.1.0", - "@babel/types": "^7.4.4", - "lodash": "^4.17.11" + "@babel/helper-function-name": "^7.7.4", + "@babel/types": "^7.7.4", + "lodash": "^4.17.13" } }, "@babel/helper-explode-assignable-expression": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.1.0.tgz", - "integrity": "sha512-NRQpfHrJ1msCHtKjbzs9YcMmJZOg6mQMmGRB+hbamEdG5PNpaSm95275VD92DvJKuyl0s2sFiDmMZ+EnnvufqA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.7.4.tgz", + "integrity": "sha512-2/SicuFrNSXsZNBxe5UGdLr+HZg+raWBLE9vC98bdYOKX/U6PY0mdGlYUJdtTDPSU0Lw0PNbKKDpwYHJLn2jLg==", "dev": true, "requires": { - "@babel/traverse": "^7.1.0", - "@babel/types": "^7.0.0" + "@babel/traverse": "^7.7.4", + "@babel/types": "^7.7.4" } }, "@babel/helper-function-name": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.1.0.tgz", - "integrity": "sha512-A95XEoCpb3TO+KZzJ4S/5uW5fNe26DjBGqf1o9ucyLyCmi1dXq/B3c8iaWTfBk3VvetUxl16e8tIrd5teOCfGw==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.7.4.tgz", + "integrity": "sha512-AnkGIdiBhEuiwdoMnKm7jfPfqItZhgRaZfMg1XX3bS25INOnLPjPG1Ppnajh8eqgt5kPJnfqrRHqFqmjKDZLzQ==", "dev": true, "requires": { - "@babel/helper-get-function-arity": "^7.0.0", - "@babel/template": "^7.1.0", - "@babel/types": "^7.0.0" + "@babel/helper-get-function-arity": "^7.7.4", + "@babel/template": "^7.7.4", + "@babel/types": "^7.7.4" } }, "@babel/helper-get-function-arity": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0.tgz", - "integrity": "sha512-r2DbJeg4svYvt3HOS74U4eWKsUAMRH01Z1ds1zx8KNTPtpTL5JAsdFv8BNyOpVqdFhHkkRDIg5B4AsxmkjAlmQ==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.7.4.tgz", + "integrity": "sha512-QTGKEdCkjgzgfJ3bAyRwF4yyT3pg+vDgan8DSivq1eS0gwi+KGKE5x8kRcbeFTb/673mkO5SN1IZfmCfA5o+EA==", "dev": true, "requires": { - "@babel/types": "^7.0.0" + "@babel/types": "^7.7.4" } }, "@babel/helper-hoist-variables": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.4.4.tgz", - "integrity": "sha512-VYk2/H/BnYbZDDg39hr3t2kKyifAm1W6zHRfhx8jGjIHpQEBv9dry7oQ2f3+J703TLu69nYdxsovl0XYfcnK4w==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.7.4.tgz", + "integrity": "sha512-wQC4xyvc1Jo/FnLirL6CEgPgPCa8M74tOdjWpRhQYapz5JC7u3NYU1zCVoVAGCE3EaIP9T1A3iW0WLJ+reZlpQ==", "dev": true, "requires": { - "@babel/types": "^7.4.4" + "@babel/types": "^7.7.4" } }, "@babel/helper-member-expression-to-functions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.0.0.tgz", - "integrity": "sha512-avo+lm/QmZlv27Zsi0xEor2fKcqWG56D5ae9dzklpIaY7cQMK5N8VSpaNVPPagiqmy7LrEjK1IWdGMOqPu5csg==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.7.4.tgz", + "integrity": "sha512-9KcA1X2E3OjXl/ykfMMInBK+uVdfIVakVe7W7Lg3wfXUNyS3Q1HWLFRwZIjhqiCGbslummPDnmb7vIekS0C1vw==", "dev": true, "requires": { - "@babel/types": "^7.0.0" + "@babel/types": "^7.7.4" } }, "@babel/helper-module-imports": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.0.0.tgz", - "integrity": "sha512-aP/hlLq01DWNEiDg4Jn23i+CXxW/owM4WpDLFUbpjxe4NS3BhLVZQ5i7E0ZrxuQ/vwekIeciyamgB1UIYxxM6A==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.7.4.tgz", + "integrity": "sha512-dGcrX6K9l8258WFjyDLJwuVKxR4XZfU0/vTUgOQYWEnRD8mgr+p4d6fCUMq/ys0h4CCt/S5JhbvtyErjWouAUQ==", "dev": true, "requires": { - "@babel/types": "^7.0.0" + "@babel/types": "^7.7.4" } }, "@babel/helper-module-transforms": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.4.4.tgz", - "integrity": "sha512-3Z1yp8TVQf+B4ynN7WoHPKS8EkdTbgAEy0nU0rs/1Kw4pDgmvYH3rz3aI11KgxKCba2cn7N+tqzV1mY2HMN96w==", + "version": "7.7.5", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.7.5.tgz", + "integrity": "sha512-A7pSxyJf1gN5qXVcidwLWydjftUN878VkalhXX5iQDuGyiGK3sOrrKKHF4/A4fwHtnsotv/NipwAeLzY4KQPvw==", "dev": true, "requires": { - "@babel/helper-module-imports": "^7.0.0", - "@babel/helper-simple-access": "^7.1.0", - "@babel/helper-split-export-declaration": "^7.4.4", - "@babel/template": "^7.4.4", - "@babel/types": "^7.4.4", - "lodash": "^4.17.11" + "@babel/helper-module-imports": "^7.7.4", + "@babel/helper-simple-access": "^7.7.4", + "@babel/helper-split-export-declaration": "^7.7.4", + "@babel/template": "^7.7.4", + "@babel/types": "^7.7.4", + "lodash": "^4.17.13" } }, "@babel/helper-optimise-call-expression": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.0.0.tgz", - "integrity": "sha512-u8nd9NQePYNQV8iPWu/pLLYBqZBa4ZaY1YWRFMuxrid94wKI1QNt67NEZ7GAe5Kc/0LLScbim05xZFWkAdrj9g==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.7.4.tgz", + "integrity": "sha512-VB7gWZ2fDkSuqW6b1AKXkJWO5NyNI3bFL/kK79/30moK57blr6NbH8xcl2XcKCwOmJosftWunZqfO84IGq3ZZg==", "dev": true, "requires": { - "@babel/types": "^7.0.0" + "@babel/types": "^7.7.4" } }, "@babel/helper-plugin-utils": { @@ -311,79 +235,79 @@ "dev": true }, "@babel/helper-regex": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@babel/helper-regex/-/helper-regex-7.4.4.tgz", - "integrity": "sha512-Y5nuB/kESmR3tKjU8Nkn1wMGEx1tjJX076HBMeL3XLQCu6vA/YRzuTW0bbb+qRnXvQGn+d6Rx953yffl8vEy7Q==", + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/helper-regex/-/helper-regex-7.5.5.tgz", + "integrity": "sha512-CkCYQLkfkiugbRDO8eZn6lRuR8kzZoGXCg3149iTk5se7g6qykSpy3+hELSwquhu+TgHn8nkLiBwHvNX8Hofcw==", "dev": true, "requires": { - "lodash": "^4.17.11" + "lodash": "^4.17.13" } }, "@babel/helper-remap-async-to-generator": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.1.0.tgz", - "integrity": "sha512-3fOK0L+Fdlg8S5al8u/hWE6vhufGSn0bN09xm2LXMy//REAF8kDCrYoOBKYmA8m5Nom+sV9LyLCwrFynA8/slg==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.7.4.tgz", + "integrity": "sha512-Sk4xmtVdM9sA/jCI80f+KS+Md+ZHIpjuqmYPk1M7F/upHou5e4ReYmExAiu6PVe65BhJPZA2CY9x9k4BqE5klw==", "dev": true, "requires": { - "@babel/helper-annotate-as-pure": "^7.0.0", - "@babel/helper-wrap-function": "^7.1.0", - "@babel/template": "^7.1.0", - "@babel/traverse": "^7.1.0", - "@babel/types": "^7.0.0" + "@babel/helper-annotate-as-pure": "^7.7.4", + "@babel/helper-wrap-function": "^7.7.4", + "@babel/template": "^7.7.4", + "@babel/traverse": "^7.7.4", + "@babel/types": "^7.7.4" } }, "@babel/helper-replace-supers": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.4.4.tgz", - "integrity": "sha512-04xGEnd+s01nY1l15EuMS1rfKktNF+1CkKmHoErDppjAAZL+IUBZpzT748x262HF7fibaQPhbvWUl5HeSt1EXg==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.7.4.tgz", + "integrity": "sha512-pP0tfgg9hsZWo5ZboYGuBn/bbYT/hdLPVSS4NMmiRJdwWhP0IznPwN9AE1JwyGsjSPLC364I0Qh5p+EPkGPNpg==", "dev": true, "requires": { - "@babel/helper-member-expression-to-functions": "^7.0.0", - "@babel/helper-optimise-call-expression": "^7.0.0", - "@babel/traverse": "^7.4.4", - "@babel/types": "^7.4.4" + "@babel/helper-member-expression-to-functions": "^7.7.4", + "@babel/helper-optimise-call-expression": "^7.7.4", + "@babel/traverse": "^7.7.4", + "@babel/types": "^7.7.4" } }, "@babel/helper-simple-access": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.1.0.tgz", - "integrity": "sha512-Vk+78hNjRbsiu49zAPALxTb+JUQCz1aolpd8osOF16BGnLtseD21nbHgLPGUwrXEurZgiCOUmvs3ExTu4F5x6w==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.7.4.tgz", + "integrity": "sha512-zK7THeEXfan7UlWsG2A6CI/L9jVnI5+xxKZOdej39Y0YtDYKx9raHk5F2EtK9K8DHRTihYwg20ADt9S36GR78A==", "dev": true, "requires": { - "@babel/template": "^7.1.0", - "@babel/types": "^7.0.0" + "@babel/template": "^7.7.4", + "@babel/types": "^7.7.4" } }, "@babel/helper-split-export-declaration": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.4.4.tgz", - "integrity": "sha512-Ro/XkzLf3JFITkW6b+hNxzZ1n5OQ80NvIUdmHspih1XAhtN3vPTuUFT4eQnela+2MaZ5ulH+iyP513KJrxbN7Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.7.4.tgz", + "integrity": "sha512-guAg1SXFcVr04Guk9eq0S4/rWS++sbmyqosJzVs8+1fH5NI+ZcmkaSkc7dmtAFbHFva6yRJnjW3yAcGxjueDug==", "dev": true, "requires": { - "@babel/types": "^7.4.4" + "@babel/types": "^7.7.4" } }, "@babel/helper-wrap-function": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.2.0.tgz", - "integrity": "sha512-o9fP1BZLLSrYlxYEYyl2aS+Flun5gtjTIG8iln+XuEzQTs0PLagAGSXUcqruJwD5fM48jzIEggCKpIfWTcR7pQ==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.7.4.tgz", + "integrity": "sha512-VsfzZt6wmsocOaVU0OokwrIytHND55yvyT4BPB9AIIgwr8+x7617hetdJTsuGwygN5RC6mxA9EJztTjuwm2ofg==", "dev": true, "requires": { - "@babel/helper-function-name": "^7.1.0", - "@babel/template": "^7.1.0", - "@babel/traverse": "^7.1.0", - "@babel/types": "^7.2.0" + "@babel/helper-function-name": "^7.7.4", + "@babel/template": "^7.7.4", + "@babel/traverse": "^7.7.4", + "@babel/types": "^7.7.4" } }, "@babel/helpers": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.5.4.tgz", - "integrity": "sha512-6LJ6xwUEJP51w0sIgKyfvFMJvIb9mWAfohJp0+m6eHJigkFdcH8duZ1sfhn0ltJRzwUIT/yqqhdSfRpCpL7oow==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.7.4.tgz", + "integrity": "sha512-ak5NGZGJ6LV85Q1Zc9gn2n+ayXOizryhjSUBTdu5ih1tlVCJeuQENzc4ItyCVhINVXvIT/ZQ4mheGIsfBkpskg==", "dev": true, "requires": { - "@babel/template": "^7.4.4", - "@babel/traverse": "^7.5.0", - "@babel/types": "^7.5.0" + "@babel/template": "^7.7.4", + "@babel/traverse": "^7.7.4", + "@babel/types": "^7.7.4" } }, "@babel/highlight": { @@ -398,451 +322,458 @@ } }, "@babel/parser": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.5.0.tgz", - "integrity": "sha512-I5nW8AhGpOXGCCNYGc+p7ExQIBxRFnS2fd/d862bNOKvmoEPjYPcfIjsfdy0ujagYOIYPczKgD9l3FsgTkAzKA==", + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.7.7.tgz", + "integrity": "sha512-WtTZMZAZLbeymhkd/sEaPD8IQyGAhmuTuvTzLiCFM7iXiVdY0gc0IaI+cW0fh1BnSMbJSzXX6/fHllgHKwHhXw==", "dev": true }, "@babel/plugin-proposal-async-generator-functions": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.2.0.tgz", - "integrity": "sha512-+Dfo/SCQqrwx48ptLVGLdE39YtWRuKc/Y9I5Fy0P1DDBB9lsAHpjcEJQt+4IifuSOSTLBKJObJqMvaO1pIE8LQ==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.7.4.tgz", + "integrity": "sha512-1ypyZvGRXriY/QP668+s8sFr2mqinhkRDMPSQLNghCQE+GAkFtp+wkHVvg2+Hdki8gwP+NFzJBJ/N1BfzCCDEw==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0", - "@babel/helper-remap-async-to-generator": "^7.1.0", - "@babel/plugin-syntax-async-generators": "^7.2.0" + "@babel/helper-remap-async-to-generator": "^7.7.4", + "@babel/plugin-syntax-async-generators": "^7.7.4" } }, "@babel/plugin-proposal-class-properties": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.5.5.tgz", - "integrity": "sha512-AF79FsnWFxjlaosgdi421vmYG6/jg79bVD0dpD44QdgobzHKuLZ6S3vl8la9qIeSwGi8i1fS0O1mfuDAAdo1/A==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.7.4.tgz", + "integrity": "sha512-EcuXeV4Hv1X3+Q1TsuOmyyxeTRiSqurGJ26+I/FW1WbymmRRapVORm6x1Zl3iDIHyRxEs+VXWp6qnlcfcJSbbw==", "dev": true, "requires": { - "@babel/helper-create-class-features-plugin": "^7.5.5", + "@babel/helper-create-class-features-plugin": "^7.7.4", "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-proposal-dynamic-import": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.5.0.tgz", - "integrity": "sha512-x/iMjggsKTFHYC6g11PL7Qy58IK8H5zqfm9e6hu4z1iH2IRyAp9u9dL80zA6R76yFovETFLKz2VJIC2iIPBuFw==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.7.4.tgz", + "integrity": "sha512-StH+nGAdO6qDB1l8sZ5UBV8AC3F2VW2I8Vfld73TMKyptMU9DY5YsJAS8U81+vEtxcH3Y/La0wG0btDrhpnhjQ==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0", - "@babel/plugin-syntax-dynamic-import": "^7.2.0" + "@babel/plugin-syntax-dynamic-import": "^7.7.4" } }, "@babel/plugin-proposal-function-bind": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-function-bind/-/plugin-proposal-function-bind-7.2.0.tgz", - "integrity": "sha512-qOFJ/eX1Is78sywwTxDcsntLOdb5ZlHVVqUz5xznq8ldAfOVIyZzp1JE2rzHnaksZIhrqMrwIpQL/qcEprnVbw==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-function-bind/-/plugin-proposal-function-bind-7.7.4.tgz", + "integrity": "sha512-0qJlxfYKHs/JUg+JFISl29YObUCKAOQ0ENHMYoxErBFp58XTXwQEsrVPhs2TGL3cxI21XPs2fpommO6zmCd3/A==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0", - "@babel/plugin-syntax-function-bind": "^7.2.0" + "@babel/plugin-syntax-function-bind": "^7.7.4" } }, "@babel/plugin-proposal-json-strings": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.2.0.tgz", - "integrity": "sha512-MAFV1CA/YVmYwZG0fBQyXhmj0BHCB5egZHCKWIFVv/XCxAeVGIHfos3SwDck4LvCllENIAg7xMKOG5kH0dzyUg==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.7.4.tgz", + "integrity": "sha512-wQvt3akcBTfLU/wYoqm/ws7YOAQKu8EVJEvHip/mzkNtjaclQoCCIqKXFP5/eyfnfbQCDV3OLRIK3mIVyXuZlw==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0", - "@babel/plugin-syntax-json-strings": "^7.2.0" + "@babel/plugin-syntax-json-strings": "^7.7.4" } }, "@babel/plugin-proposal-object-rest-spread": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.5.4.tgz", - "integrity": "sha512-KCx0z3y7y8ipZUMAEEJOyNi11lMb/FOPUjjB113tfowgw0c16EGYos7worCKBcUAh2oG+OBnoUhsnTSoLpV9uA==", + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.7.7.tgz", + "integrity": "sha512-3qp9I8lelgzNedI3hrhkvhaEYree6+WHnyA/q4Dza9z7iEIs1eyhWyJnetk3jJ69RT0AT4G0UhEGwyGFJ7GUuQ==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0", - "@babel/plugin-syntax-object-rest-spread": "^7.2.0" + "@babel/plugin-syntax-object-rest-spread": "^7.7.4" } }, "@babel/plugin-proposal-optional-catch-binding": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.2.0.tgz", - "integrity": "sha512-mgYj3jCcxug6KUcX4OBoOJz3CMrwRfQELPQ5560F70YQUBZB7uac9fqaWamKR1iWUzGiK2t0ygzjTScZnVz75g==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.7.4.tgz", + "integrity": "sha512-DyM7U2bnsQerCQ+sejcTNZh8KQEUuC3ufzdnVnSiUv/qoGJp2Z3hanKL18KDhsBT5Wj6a7CMT5mdyCNJsEaA9w==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0", - "@babel/plugin-syntax-optional-catch-binding": "^7.2.0" + "@babel/plugin-syntax-optional-catch-binding": "^7.7.4" } }, "@babel/plugin-proposal-unicode-property-regex": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.4.4.tgz", - "integrity": "sha512-j1NwnOqMG9mFUOH58JTFsA/+ZYzQLUZ/drqWUqxCYLGeu2JFZL8YrNC9hBxKmWtAuOCHPcRpgv7fhap09Fb4kA==", + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.7.7.tgz", + "integrity": "sha512-80PbkKyORBUVm1fbTLrHpYdJxMThzM1UqFGh0ALEhO9TYbG86Ah9zQYAB/84axz2vcxefDLdZwWwZNlYARlu9w==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.0.0", - "@babel/helper-regex": "^7.4.4", - "regexpu-core": "^4.5.4" + "@babel/helper-create-regexp-features-plugin": "^7.7.4", + "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-syntax-async-generators": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.2.0.tgz", - "integrity": "sha512-1ZrIRBv2t0GSlcwVoQ6VgSLpLgiN/FVQUzt9znxo7v2Ov4jJrs8RY8tv0wvDmFN3qIdMKWrmMMW6yZ0G19MfGg==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.7.4.tgz", + "integrity": "sha512-Li4+EjSpBgxcsmeEF8IFcfV/+yJGxHXDirDkEoyFjumuwbmfCVHUt0HuowD/iGM7OhIRyXJH9YXxqiH6N815+g==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-syntax-dynamic-import": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.2.0.tgz", - "integrity": "sha512-mVxuJ0YroI/h/tbFTPGZR8cv6ai+STMKNBq0f8hFxsxWjl94qqhsb+wXbpNMDPU3cfR1TIsVFzU3nXyZMqyK4w==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.7.4.tgz", + "integrity": "sha512-jHQW0vbRGvwQNgyVxwDh4yuXu4bH1f5/EICJLAhl1SblLs2CDhrsmCk+v5XLdE9wxtAFRyxx+P//Iw+a5L/tTg==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-syntax-function-bind": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-function-bind/-/plugin-syntax-function-bind-7.2.0.tgz", - "integrity": "sha512-/WzU1lLU2l0wDfB42Wkg6tahrmtBbiD8C4H6EGSX0M4GAjzN6JiOpq/Uh8G6GSoR6lPMvhjM0MNiV6znj6y/zg==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-function-bind/-/plugin-syntax-function-bind-7.7.4.tgz", + "integrity": "sha512-dF3QkkaFA3Z7eiD2Cv7Y5x4w2sAKQVHUV2hLqi9iPKexw+/oqpL4crnnalg/Lq31XN33cH3G41kONSCqu06i/Q==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-syntax-json-strings": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.2.0.tgz", - "integrity": "sha512-5UGYnMSLRE1dqqZwug+1LISpA403HzlSfsg6P9VXU6TBjcSHeNlw4DxDx7LgpF+iKZoOG/+uzqoRHTdcUpiZNg==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.7.4.tgz", + "integrity": "sha512-QpGupahTQW1mHRXddMG5srgpHWqRLwJnJZKXTigB9RPFCCGbDGCgBeM/iC82ICXp414WeYx/tD54w7M2qRqTMg==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-syntax-jsx": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.2.0.tgz", - "integrity": "sha512-VyN4QANJkRW6lDBmENzRszvZf3/4AXaj9YR7GwrWeeN9tEBPuXbmDYVU9bYBN0D70zCWVwUy0HWq2553VCb6Hw==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.7.4.tgz", + "integrity": "sha512-wuy6fiMe9y7HeZBWXYCGt2RGxZOj0BImZ9EyXJVnVGBKO/Br592rbR3rtIQn0eQhAk9vqaKP5n8tVqEFBQMfLg==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-syntax-object-rest-spread": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.2.0.tgz", - "integrity": "sha512-t0JKGgqk2We+9may3t0xDdmneaXmyxq0xieYcKHxIsrJO64n1OiMWNUtc5gQK1PA0NpdCRrtZp4z+IUaKugrSA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.7.4.tgz", + "integrity": "sha512-mObR+r+KZq0XhRVS2BrBKBpr5jqrqzlPvS9C9vuOf5ilSwzloAl7RPWLrgKdWS6IreaVrjHxTjtyqFiOisaCwg==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-syntax-optional-catch-binding": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.2.0.tgz", - "integrity": "sha512-bDe4xKNhb0LI7IvZHiA13kff0KEfaGX/Hv4lMA9+7TEc63hMNvfKo6ZFpXhKuEp+II/q35Gc4NoMeDZyaUbj9w==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.7.4.tgz", + "integrity": "sha512-4ZSuzWgFxqHRE31Glu+fEr/MirNZOMYmD/0BhBWyLyOOQz/gTAl7QmWm2hX1QxEIXsr2vkdlwxIzTyiYRC4xcQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-syntax-top-level-await": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.7.4.tgz", + "integrity": "sha512-wdsOw0MvkL1UIgiQ/IFr3ETcfv1xb8RMM0H9wbiDyLaJFyiDg5oZvDLCXosIXmFeIlweML5iOBXAkqddkYNizg==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-transform-arrow-functions": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.2.0.tgz", - "integrity": "sha512-ER77Cax1+8/8jCB9fo4Ud161OZzWN5qawi4GusDuRLcDbDG+bIGYY20zb2dfAFdTRGzrfq2xZPvF0R64EHnimg==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.7.4.tgz", + "integrity": "sha512-zUXy3e8jBNPiffmqkHRNDdZM2r8DWhCB7HhcoyZjiK1TxYEluLHAvQuYnTT+ARqRpabWqy/NHkO6e3MsYB5YfA==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-transform-async-to-generator": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.5.0.tgz", - "integrity": "sha512-mqvkzwIGkq0bEF1zLRRiTdjfomZJDV33AH3oQzHVGkI2VzEmXLpKKOBvEVaFZBJdN0XTyH38s9j/Kiqr68dggg==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.7.4.tgz", + "integrity": "sha512-zpUTZphp5nHokuy8yLlyafxCJ0rSlFoSHypTUWgpdwoDXWQcseaect7cJ8Ppk6nunOM6+5rPMkod4OYKPR5MUg==", "dev": true, "requires": { - "@babel/helper-module-imports": "^7.0.0", + "@babel/helper-module-imports": "^7.7.4", "@babel/helper-plugin-utils": "^7.0.0", - "@babel/helper-remap-async-to-generator": "^7.1.0" + "@babel/helper-remap-async-to-generator": "^7.7.4" } }, "@babel/plugin-transform-block-scoped-functions": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.2.0.tgz", - "integrity": "sha512-ntQPR6q1/NKuphly49+QiQiTN0O63uOwjdD6dhIjSWBI5xlrbUFh720TIpzBhpnrLfv2tNH/BXvLIab1+BAI0w==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.7.4.tgz", + "integrity": "sha512-kqtQzwtKcpPclHYjLK//3lH8OFsCDuDJBaFhVwf8kqdnF6MN4l618UDlcA7TfRs3FayrHj+svYnSX8MC9zmUyQ==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-transform-block-scoping": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.4.4.tgz", - "integrity": "sha512-jkTUyWZcTrwxu5DD4rWz6rDB5Cjdmgz6z7M7RLXOJyCUkFBawssDGcGh8M/0FTSB87avyJI1HsTwUXp9nKA1PA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.7.4.tgz", + "integrity": "sha512-2VBe9u0G+fDt9B5OV5DQH4KBf5DoiNkwFKOz0TCvBWvdAN2rOykCTkrL+jTLxfCAm76l9Qo5OqL7HBOx2dWggg==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0", - "lodash": "^4.17.11" + "lodash": "^4.17.13" } }, "@babel/plugin-transform-classes": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.4.4.tgz", - "integrity": "sha512-/e44eFLImEGIpL9qPxSRat13I5QNRgBLu2hOQJCF7VLy/otSM/sypV1+XaIw5+502RX/+6YaSAPmldk+nhHDPw==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.7.4.tgz", + "integrity": "sha512-sK1mjWat7K+buWRuImEzjNf68qrKcrddtpQo3swi9j7dUcG6y6R6+Di039QN2bD1dykeswlagupEmpOatFHHUg==", "dev": true, "requires": { - "@babel/helper-annotate-as-pure": "^7.0.0", - "@babel/helper-define-map": "^7.4.4", - "@babel/helper-function-name": "^7.1.0", - "@babel/helper-optimise-call-expression": "^7.0.0", + "@babel/helper-annotate-as-pure": "^7.7.4", + "@babel/helper-define-map": "^7.7.4", + "@babel/helper-function-name": "^7.7.4", + "@babel/helper-optimise-call-expression": "^7.7.4", "@babel/helper-plugin-utils": "^7.0.0", - "@babel/helper-replace-supers": "^7.4.4", - "@babel/helper-split-export-declaration": "^7.4.4", + "@babel/helper-replace-supers": "^7.7.4", + "@babel/helper-split-export-declaration": "^7.7.4", "globals": "^11.1.0" } }, "@babel/plugin-transform-computed-properties": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.2.0.tgz", - "integrity": "sha512-kP/drqTxY6Xt3NNpKiMomfgkNn4o7+vKxK2DDKcBG9sHj51vHqMBGy8wbDS/J4lMxnqs153/T3+DmCEAkC5cpA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.7.4.tgz", + "integrity": "sha512-bSNsOsZnlpLLyQew35rl4Fma3yKWqK3ImWMSC/Nc+6nGjC9s5NFWAer1YQ899/6s9HxO2zQC1WoFNfkOqRkqRQ==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-transform-destructuring": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.5.0.tgz", - "integrity": "sha512-YbYgbd3TryYYLGyC7ZR+Tq8H/+bCmwoaxHfJHupom5ECstzbRLTch6gOQbhEY9Z4hiCNHEURgq06ykFv9JZ/QQ==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.7.4.tgz", + "integrity": "sha512-4jFMXI1Cu2aXbcXXl8Lr6YubCn6Oc7k9lLsu8v61TZh+1jny2BWmdtvY9zSUlLdGUvcy9DMAWyZEOqjsbeg/wA==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-transform-dotall-regex": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.4.4.tgz", - "integrity": "sha512-P05YEhRc2h53lZDjRPk/OektxCVevFzZs2Gfjd545Wde3k+yFDbXORgl2e0xpbq8mLcKJ7Idss4fAg0zORN/zg==", + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.7.7.tgz", + "integrity": "sha512-b4in+YlTeE/QmTgrllnb3bHA0HntYvjz8O3Mcbx75UBPJA2xhb5A8nle498VhxSXJHQefjtQxpnLPehDJ4TRlg==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.0.0", - "@babel/helper-regex": "^7.4.4", - "regexpu-core": "^4.5.4" + "@babel/helper-create-regexp-features-plugin": "^7.7.4", + "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-transform-duplicate-keys": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.5.0.tgz", - "integrity": "sha512-igcziksHizyQPlX9gfSjHkE2wmoCH3evvD2qR5w29/Dk0SMKE/eOI7f1HhBdNhR/zxJDqrgpoDTq5YSLH/XMsQ==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.7.4.tgz", + "integrity": "sha512-g1y4/G6xGWMD85Tlft5XedGaZBCIVN+/P0bs6eabmcPP9egFleMAo65OOjlhcz1njpwagyY3t0nsQC9oTFegJA==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-transform-exponentiation-operator": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.2.0.tgz", - "integrity": "sha512-umh4hR6N7mu4Elq9GG8TOu9M0bakvlsREEC+ialrQN6ABS4oDQ69qJv1VtR3uxlKMCQMCvzk7vr17RHKcjx68A==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.7.4.tgz", + "integrity": "sha512-MCqiLfCKm6KEA1dglf6Uqq1ElDIZwFuzz1WH5mTf8k2uQSxEJMbOIEh7IZv7uichr7PMfi5YVSrr1vz+ipp7AQ==", "dev": true, "requires": { - "@babel/helper-builder-binary-assignment-operator-visitor": "^7.1.0", + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.7.4", "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-transform-for-of": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.4.4.tgz", - "integrity": "sha512-9T/5Dlr14Z9TIEXLXkt8T1DU7F24cbhwhMNUziN3hB1AXoZcdzPcTiKGRn/6iOymDqtTKWnr/BtRKN9JwbKtdQ==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.7.4.tgz", + "integrity": "sha512-zZ1fD1B8keYtEcKF+M1TROfeHTKnijcVQm0yO/Yu1f7qoDoxEIc/+GX6Go430Bg84eM/xwPFp0+h4EbZg7epAA==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-transform-function-name": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.4.4.tgz", - "integrity": "sha512-iU9pv7U+2jC9ANQkKeNF6DrPy4GBa4NWQtl6dHB4Pb3izX2JOEvDTFarlNsBj/63ZEzNNIAMs3Qw4fNCcSOXJA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.7.4.tgz", + "integrity": "sha512-E/x09TvjHNhsULs2IusN+aJNRV5zKwxu1cpirZyRPw+FyyIKEHPXTsadj48bVpc1R5Qq1B5ZkzumuFLytnbT6g==", "dev": true, "requires": { - "@babel/helper-function-name": "^7.1.0", + "@babel/helper-function-name": "^7.7.4", "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-transform-literals": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.2.0.tgz", - "integrity": "sha512-2ThDhm4lI4oV7fVQ6pNNK+sx+c/GM5/SaML0w/r4ZB7sAneD/piDJtwdKlNckXeyGK7wlwg2E2w33C/Hh+VFCg==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.7.4.tgz", + "integrity": "sha512-X2MSV7LfJFm4aZfxd0yLVFrEXAgPqYoDG53Br/tCKiKYfX0MjVjQeWPIhPHHsCqzwQANq+FLN786fF5rgLS+gw==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-transform-member-expression-literals": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.2.0.tgz", - "integrity": "sha512-HiU3zKkSU6scTidmnFJ0bMX8hz5ixC93b4MHMiYebmk2lUVNGOboPsqQvx5LzooihijUoLR/v7Nc1rbBtnc7FA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.7.4.tgz", + "integrity": "sha512-9VMwMO7i69LHTesL0RdGy93JU6a+qOPuvB4F4d0kR0zyVjJRVJRaoaGjhtki6SzQUu8yen/vxPKN6CWnCUw6bA==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-transform-modules-amd": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.5.0.tgz", - "integrity": "sha512-n20UsQMKnWrltocZZm24cRURxQnWIvsABPJlw/fvoy9c6AgHZzoelAIzajDHAQrDpuKFFPPcFGd7ChsYuIUMpg==", + "version": "7.7.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.7.5.tgz", + "integrity": "sha512-CT57FG4A2ZUNU1v+HdvDSDrjNWBrtCmSH6YbbgN3Lrf0Di/q/lWRxZrE72p3+HCCz9UjfZOEBdphgC0nzOS6DQ==", "dev": true, "requires": { - "@babel/helper-module-transforms": "^7.1.0", + "@babel/helper-module-transforms": "^7.7.5", "@babel/helper-plugin-utils": "^7.0.0", "babel-plugin-dynamic-import-node": "^2.3.0" } }, "@babel/plugin-transform-modules-commonjs": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.5.0.tgz", - "integrity": "sha512-xmHq0B+ytyrWJvQTc5OWAC4ii6Dhr0s22STOoydokG51JjWhyYo5mRPXoi+ZmtHQhZZwuXNN+GG5jy5UZZJxIQ==", + "version": "7.7.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.7.5.tgz", + "integrity": "sha512-9Cq4zTFExwFhQI6MT1aFxgqhIsMWQWDVwOgLzl7PTWJHsNaqFvklAU+Oz6AQLAS0dJKTwZSOCo20INwktxpi3Q==", "dev": true, "requires": { - "@babel/helper-module-transforms": "^7.4.4", + "@babel/helper-module-transforms": "^7.7.5", "@babel/helper-plugin-utils": "^7.0.0", - "@babel/helper-simple-access": "^7.1.0", + "@babel/helper-simple-access": "^7.7.4", "babel-plugin-dynamic-import-node": "^2.3.0" } }, "@babel/plugin-transform-modules-systemjs": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.5.0.tgz", - "integrity": "sha512-Q2m56tyoQWmuNGxEtUyeEkm6qJYFqs4c+XyXH5RAuYxObRNz9Zgj/1g2GMnjYp2EUyEy7YTrxliGCXzecl/vJg==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.7.4.tgz", + "integrity": "sha512-y2c96hmcsUi6LrMqvmNDPBBiGCiQu0aYqpHatVVu6kD4mFEXKjyNxd/drc18XXAf9dv7UXjrZwBVmTTGaGP8iw==", "dev": true, "requires": { - "@babel/helper-hoist-variables": "^7.4.4", + "@babel/helper-hoist-variables": "^7.7.4", "@babel/helper-plugin-utils": "^7.0.0", "babel-plugin-dynamic-import-node": "^2.3.0" } }, "@babel/plugin-transform-modules-umd": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.2.0.tgz", - "integrity": "sha512-BV3bw6MyUH1iIsGhXlOK6sXhmSarZjtJ/vMiD9dNmpY8QXFFQTj+6v92pcfy1iqa8DeAfJFwoxcrS/TUZda6sw==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.7.4.tgz", + "integrity": "sha512-u2B8TIi0qZI4j8q4C51ktfO7E3cQ0qnaXFI1/OXITordD40tt17g/sXqgNNCcMTcBFKrUPcGDx+TBJuZxLx7tw==", "dev": true, "requires": { - "@babel/helper-module-transforms": "^7.1.0", + "@babel/helper-module-transforms": "^7.7.4", "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.4.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.4.5.tgz", - "integrity": "sha512-z7+2IsWafTBbjNsOxU/Iv5CvTJlr5w4+HGu1HovKYTtgJ362f7kBcQglkfmlspKKZ3bgrbSGvLfNx++ZJgCWsg==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.7.4.tgz", + "integrity": "sha512-jBUkiqLKvUWpv9GLSuHUFYdmHg0ujC1JEYoZUfeOOfNydZXp1sXObgyPatpcwjWgsdBGsagWW0cdJpX/DO2jMw==", "dev": true, "requires": { - "regexp-tree": "^0.1.6" + "@babel/helper-create-regexp-features-plugin": "^7.7.4" } }, "@babel/plugin-transform-new-target": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.4.4.tgz", - "integrity": "sha512-r1z3T2DNGQwwe2vPGZMBNjioT2scgWzK9BCnDEh+46z8EEwXBq24uRzd65I7pjtugzPSj921aM15RpESgzsSuA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.7.4.tgz", + "integrity": "sha512-CnPRiNtOG1vRodnsyGX37bHQleHE14B9dnnlgSeEs3ek3fHN1A1SScglTCg1sfbe7sRQ2BUcpgpTpWSfMKz3gg==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-transform-object-super": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.2.0.tgz", - "integrity": "sha512-VMyhPYZISFZAqAPVkiYb7dUe2AsVi2/wCT5+wZdsNO31FojQJa9ns40hzZ6U9f50Jlq4w6qwzdBB2uwqZ00ebg==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.7.4.tgz", + "integrity": "sha512-ho+dAEhC2aRnff2JCA0SAK7V2R62zJd/7dmtoe7MHcso4C2mS+vZjn1Pb1pCVZvJs1mgsvv5+7sT+m3Bysb6eg==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0", - "@babel/helper-replace-supers": "^7.1.0" + "@babel/helper-replace-supers": "^7.7.4" } }, "@babel/plugin-transform-parameters": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.4.4.tgz", - "integrity": "sha512-oMh5DUO1V63nZcu/ZVLQFqiihBGo4OpxJxR1otF50GMeCLiRx5nUdtokd+u9SuVJrvvuIh9OosRFPP4pIPnwmw==", + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.7.7.tgz", + "integrity": "sha512-OhGSrf9ZBrr1fw84oFXj5hgi8Nmg+E2w5L7NhnG0lPvpDtqd7dbyilM2/vR8CKbJ907RyxPh2kj6sBCSSfI9Ew==", "dev": true, "requires": { - "@babel/helper-call-delegate": "^7.4.4", - "@babel/helper-get-function-arity": "^7.0.0", + "@babel/helper-call-delegate": "^7.7.4", + "@babel/helper-get-function-arity": "^7.7.4", "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-transform-property-literals": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.2.0.tgz", - "integrity": "sha512-9q7Dbk4RhgcLp8ebduOpCbtjh7C0itoLYHXd9ueASKAG/is5PQtMR5VJGka9NKqGhYEGn5ITahd4h9QeBMylWQ==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.7.4.tgz", + "integrity": "sha512-MatJhlC4iHsIskWYyawl53KuHrt+kALSADLQQ/HkhTjX954fkxIEh4q5slL4oRAnsm/eDoZ4q0CIZpcqBuxhJQ==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-transform-react-jsx": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.3.0.tgz", - "integrity": "sha512-a/+aRb7R06WcKvQLOu4/TpjKOdvVEKRLWFpKcNuHhiREPgGRB4TQJxq07+EZLS8LFVYpfq1a5lDUnuMdcCpBKg==", + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.7.7.tgz", + "integrity": "sha512-SlPjWPbva2+7/ZJbGcoqjl4LsQaLpKEzxW9hcxU7675s24JmdotJOSJ4cgAbV82W3FcZpHIGmRZIlUL8ayMvjw==", "dev": true, "requires": { - "@babel/helper-builder-react-jsx": "^7.3.0", + "@babel/helper-builder-react-jsx": "^7.7.4", "@babel/helper-plugin-utils": "^7.0.0", - "@babel/plugin-syntax-jsx": "^7.2.0" + "@babel/plugin-syntax-jsx": "^7.7.4" } }, "@babel/plugin-transform-regenerator": { - "version": "7.4.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.4.5.tgz", - "integrity": "sha512-gBKRh5qAaCWntnd09S8QC7r3auLCqq5DI6O0DlfoyDjslSBVqBibrMdsqO+Uhmx3+BlOmE/Kw1HFxmGbv0N9dA==", + "version": "7.7.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.7.5.tgz", + "integrity": "sha512-/8I8tPvX2FkuEyWbjRCt4qTAgZK0DVy8QRguhA524UH48RfGJy94On2ri+dCuwOpcerPRl9O4ebQkRcVzIaGBw==", "dev": true, "requires": { "regenerator-transform": "^0.14.0" } }, "@babel/plugin-transform-reserved-words": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.2.0.tgz", - "integrity": "sha512-fz43fqW8E1tAB3DKF19/vxbpib1fuyCwSPE418ge5ZxILnBhWyhtPgz8eh1RCGGJlwvksHkyxMxh0eenFi+kFw==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.7.4.tgz", + "integrity": "sha512-OrPiUB5s5XvkCO1lS7D8ZtHcswIC57j62acAnJZKqGGnHP+TIc/ljQSrgdX/QyOTdEK5COAhuc820Hi1q2UgLQ==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-transform-runtime": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.5.5.tgz", - "integrity": "sha512-6Xmeidsun5rkwnGfMOp6/z9nSzWpHFNVr2Jx7kwoq4mVatQfQx5S56drBgEHF+XQbKOdIaOiMIINvp/kAwMN+w==", + "version": "7.7.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.7.6.tgz", + "integrity": "sha512-tajQY+YmXR7JjTwRvwL4HePqoL3DYxpYXIHKVvrOIvJmeHe2y1w4tz5qz9ObUDC9m76rCzIMPyn4eERuwA4a4A==", "dev": true, "requires": { - "@babel/helper-module-imports": "^7.0.0", + "@babel/helper-module-imports": "^7.7.4", "@babel/helper-plugin-utils": "^7.0.0", "resolve": "^1.8.1", "semver": "^5.5.1" } }, "@babel/plugin-transform-shorthand-properties": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.2.0.tgz", - "integrity": "sha512-QP4eUM83ha9zmYtpbnyjTLAGKQritA5XW/iG9cjtuOI8s1RuL/3V6a3DeSHfKutJQ+ayUfeZJPcnCYEQzaPQqg==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.7.4.tgz", + "integrity": "sha512-q+suddWRfIcnyG5YiDP58sT65AJDZSUhXQDZE3r04AuqD6d/XLaQPPXSBzP2zGerkgBivqtQm9XKGLuHqBID6Q==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-transform-spread": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.2.2.tgz", - "integrity": "sha512-KWfky/58vubwtS0hLqEnrWJjsMGaOeSBn90Ezn5Jeg9Z8KKHmELbP1yGylMlm5N6TPKeY9A2+UaSYLdxahg01w==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.7.4.tgz", + "integrity": "sha512-8OSs0FLe5/80cndziPlg4R0K6HcWSM0zyNhHhLsmw/Nc5MaA49cAsnoJ/t/YZf8qkG7fD+UjTRaApVDB526d7Q==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-transform-sticky-regex": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.2.0.tgz", - "integrity": "sha512-KKYCoGaRAf+ckH8gEL3JHUaFVyNHKe3ASNsZ+AlktgHevvxGigoIttrEJb8iKN03Q7Eazlv1s6cx2B2cQ3Jabw==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.7.4.tgz", + "integrity": "sha512-Ls2NASyL6qtVe1H1hXts9yuEeONV2TJZmplLONkMPUG158CtmnrzW5Q5teibM5UVOFjG0D3IC5mzXR6pPpUY7A==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0", @@ -850,141 +781,140 @@ } }, "@babel/plugin-transform-template-literals": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.4.4.tgz", - "integrity": "sha512-mQrEC4TWkhLN0z8ygIvEL9ZEToPhG5K7KDW3pzGqOfIGZ28Jb0POUkeWcoz8HnHvhFy6dwAT1j8OzqN8s804+g==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.7.4.tgz", + "integrity": "sha512-sA+KxLwF3QwGj5abMHkHgshp9+rRz+oY9uoRil4CyLtgEuE/88dpkeWgNk5qKVsJE9iSfly3nvHapdRiIS2wnQ==", "dev": true, "requires": { - "@babel/helper-annotate-as-pure": "^7.0.0", + "@babel/helper-annotate-as-pure": "^7.7.4", "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-transform-typeof-symbol": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.2.0.tgz", - "integrity": "sha512-2LNhETWYxiYysBtrBTqL8+La0jIoQQnIScUJc74OYvUGRmkskNY4EzLCnjHBzdmb38wqtTaixpo1NctEcvMDZw==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.7.4.tgz", + "integrity": "sha512-KQPUQ/7mqe2m0B8VecdyaW5XcQYaePyl9R7IsKd+irzj6jvbhoGnRE+M0aNkyAzI07VfUQ9266L5xMARitV3wg==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/plugin-transform-unicode-regex": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.4.4.tgz", - "integrity": "sha512-il+/XdNw01i93+M9J9u4T7/e/Ue/vWfNZE4IRUQjplu2Mqb/AFTDimkw2tdEdSH50wuQXZAbXSql0UphQke+vA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.7.4.tgz", + "integrity": "sha512-N77UUIV+WCvE+5yHw+oks3m18/umd7y392Zv7mYTpFqHtkpcc+QUz+gLJNTWVlWROIWeLqY0f3OjZxV5TcXnRw==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.0.0", - "@babel/helper-regex": "^7.4.4", - "regexpu-core": "^4.5.4" + "@babel/helper-create-regexp-features-plugin": "^7.7.4", + "@babel/helper-plugin-utils": "^7.0.0" } }, "@babel/preset-env": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.5.4.tgz", - "integrity": "sha512-hFnFnouyRNiH1rL8YkX1ANCNAUVC8Djwdqfev8i1415tnAG+7hlA5zhZ0Q/3Q5gkop4HioIPbCEWAalqcbxRoQ==", + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.7.7.tgz", + "integrity": "sha512-pCu0hrSSDVI7kCVUOdcMNQEbOPJ52E+LrQ14sN8uL2ALfSqePZQlKrOy+tM4uhEdYlCHi4imr8Zz2cZe9oSdIg==", "dev": true, "requires": { - "@babel/helper-module-imports": "^7.0.0", + "@babel/helper-module-imports": "^7.7.4", "@babel/helper-plugin-utils": "^7.0.0", - "@babel/plugin-proposal-async-generator-functions": "^7.2.0", - "@babel/plugin-proposal-dynamic-import": "^7.5.0", - "@babel/plugin-proposal-json-strings": "^7.2.0", - "@babel/plugin-proposal-object-rest-spread": "^7.5.4", - "@babel/plugin-proposal-optional-catch-binding": "^7.2.0", - "@babel/plugin-proposal-unicode-property-regex": "^7.4.4", - "@babel/plugin-syntax-async-generators": "^7.2.0", - "@babel/plugin-syntax-dynamic-import": "^7.2.0", - "@babel/plugin-syntax-json-strings": "^7.2.0", - "@babel/plugin-syntax-object-rest-spread": "^7.2.0", - "@babel/plugin-syntax-optional-catch-binding": "^7.2.0", - "@babel/plugin-transform-arrow-functions": "^7.2.0", - "@babel/plugin-transform-async-to-generator": "^7.5.0", - "@babel/plugin-transform-block-scoped-functions": "^7.2.0", - "@babel/plugin-transform-block-scoping": "^7.4.4", - "@babel/plugin-transform-classes": "^7.4.4", - "@babel/plugin-transform-computed-properties": "^7.2.0", - "@babel/plugin-transform-destructuring": "^7.5.0", - "@babel/plugin-transform-dotall-regex": "^7.4.4", - "@babel/plugin-transform-duplicate-keys": "^7.5.0", - "@babel/plugin-transform-exponentiation-operator": "^7.2.0", - "@babel/plugin-transform-for-of": "^7.4.4", - "@babel/plugin-transform-function-name": "^7.4.4", - "@babel/plugin-transform-literals": "^7.2.0", - "@babel/plugin-transform-member-expression-literals": "^7.2.0", - "@babel/plugin-transform-modules-amd": "^7.5.0", - "@babel/plugin-transform-modules-commonjs": "^7.5.0", - "@babel/plugin-transform-modules-systemjs": "^7.5.0", - "@babel/plugin-transform-modules-umd": "^7.2.0", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.4.5", - "@babel/plugin-transform-new-target": "^7.4.4", - "@babel/plugin-transform-object-super": "^7.2.0", - "@babel/plugin-transform-parameters": "^7.4.4", - "@babel/plugin-transform-property-literals": "^7.2.0", - "@babel/plugin-transform-regenerator": "^7.4.5", - "@babel/plugin-transform-reserved-words": "^7.2.0", - "@babel/plugin-transform-shorthand-properties": "^7.2.0", - "@babel/plugin-transform-spread": "^7.2.0", - "@babel/plugin-transform-sticky-regex": "^7.2.0", - "@babel/plugin-transform-template-literals": "^7.4.4", - "@babel/plugin-transform-typeof-symbol": "^7.2.0", - "@babel/plugin-transform-unicode-regex": "^7.4.4", - "@babel/types": "^7.5.0", + "@babel/plugin-proposal-async-generator-functions": "^7.7.4", + "@babel/plugin-proposal-dynamic-import": "^7.7.4", + "@babel/plugin-proposal-json-strings": "^7.7.4", + "@babel/plugin-proposal-object-rest-spread": "^7.7.7", + "@babel/plugin-proposal-optional-catch-binding": "^7.7.4", + "@babel/plugin-proposal-unicode-property-regex": "^7.7.7", + "@babel/plugin-syntax-async-generators": "^7.7.4", + "@babel/plugin-syntax-dynamic-import": "^7.7.4", + "@babel/plugin-syntax-json-strings": "^7.7.4", + "@babel/plugin-syntax-object-rest-spread": "^7.7.4", + "@babel/plugin-syntax-optional-catch-binding": "^7.7.4", + "@babel/plugin-syntax-top-level-await": "^7.7.4", + "@babel/plugin-transform-arrow-functions": "^7.7.4", + "@babel/plugin-transform-async-to-generator": "^7.7.4", + "@babel/plugin-transform-block-scoped-functions": "^7.7.4", + "@babel/plugin-transform-block-scoping": "^7.7.4", + "@babel/plugin-transform-classes": "^7.7.4", + "@babel/plugin-transform-computed-properties": "^7.7.4", + "@babel/plugin-transform-destructuring": "^7.7.4", + "@babel/plugin-transform-dotall-regex": "^7.7.7", + "@babel/plugin-transform-duplicate-keys": "^7.7.4", + "@babel/plugin-transform-exponentiation-operator": "^7.7.4", + "@babel/plugin-transform-for-of": "^7.7.4", + "@babel/plugin-transform-function-name": "^7.7.4", + "@babel/plugin-transform-literals": "^7.7.4", + "@babel/plugin-transform-member-expression-literals": "^7.7.4", + "@babel/plugin-transform-modules-amd": "^7.7.5", + "@babel/plugin-transform-modules-commonjs": "^7.7.5", + "@babel/plugin-transform-modules-systemjs": "^7.7.4", + "@babel/plugin-transform-modules-umd": "^7.7.4", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.7.4", + "@babel/plugin-transform-new-target": "^7.7.4", + "@babel/plugin-transform-object-super": "^7.7.4", + "@babel/plugin-transform-parameters": "^7.7.7", + "@babel/plugin-transform-property-literals": "^7.7.4", + "@babel/plugin-transform-regenerator": "^7.7.5", + "@babel/plugin-transform-reserved-words": "^7.7.4", + "@babel/plugin-transform-shorthand-properties": "^7.7.4", + "@babel/plugin-transform-spread": "^7.7.4", + "@babel/plugin-transform-sticky-regex": "^7.7.4", + "@babel/plugin-transform-template-literals": "^7.7.4", + "@babel/plugin-transform-typeof-symbol": "^7.7.4", + "@babel/plugin-transform-unicode-regex": "^7.7.4", + "@babel/types": "^7.7.4", "browserslist": "^4.6.0", - "core-js-compat": "^3.1.1", + "core-js-compat": "^3.6.0", "invariant": "^2.2.2", "js-levenshtein": "^1.1.3", "semver": "^5.5.0" } }, "@babel/register": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.4.4.tgz", - "integrity": "sha512-sn51H88GRa00+ZoMqCVgOphmswG4b7mhf9VOB0LUBAieykq2GnRFerlN+JQkO/ntT7wz4jaHNSRPg9IdMPEUkA==", + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.7.7.tgz", + "integrity": "sha512-S2mv9a5dc2pcpg/ConlKZx/6wXaEwHeqfo7x/QbXsdCAZm+WJC1ekVvL1TVxNsedTs5y/gG63MhJTEsmwmjtiA==", "dev": true, "requires": { - "core-js": "^3.0.0", "find-cache-dir": "^2.0.0", - "lodash": "^4.17.11", - "mkdirp": "^0.5.1", + "lodash": "^4.17.13", + "make-dir": "^2.1.0", "pirates": "^4.0.0", - "source-map-support": "^0.5.9" + "source-map-support": "^0.5.16" } }, "@babel/runtime": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.5.5.tgz", - "integrity": "sha512-28QvEGyQyNkB0/m2B4FU7IEZGK2NUrcMtT6BZEFALTguLk+AUT6ofsHtPk5QyjAdUkpMJ+/Em+quwz4HOt30AQ==", + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.7.7.tgz", + "integrity": "sha512-uCnC2JEVAu8AKB5do1WRIsvrdJ0flYx/A/9f/6chdacnEZ7LmavjdsDXr5ksYBegxtuTPR5Va9/+13QF/kFkCA==", "requires": { "regenerator-runtime": "^0.13.2" } }, "@babel/template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.4.4.tgz", - "integrity": "sha512-CiGzLN9KgAvgZsnivND7rkA+AeJ9JB0ciPOD4U59GKbQP2iQl+olF1l76kJOupqidozfZ32ghwBEJDhnk9MEcw==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.7.4.tgz", + "integrity": "sha512-qUzihgVPguAzXCK7WXw8pqs6cEwi54s3E+HrejlkuWO6ivMKx9hZl3Y2fSXp9i5HgyWmj7RKP+ulaYnKM4yYxw==", "dev": true, "requires": { "@babel/code-frame": "^7.0.0", - "@babel/parser": "^7.4.4", - "@babel/types": "^7.4.4" + "@babel/parser": "^7.7.4", + "@babel/types": "^7.7.4" } }, "@babel/traverse": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.5.0.tgz", - "integrity": "sha512-SnA9aLbyOCcnnbQEGwdfBggnc142h/rbqqsXcaATj2hZcegCl903pUD/lfpsNBlBSuWow/YDfRyJuWi2EPR5cg==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.7.4.tgz", + "integrity": "sha512-P1L58hQyupn8+ezVA2z5KBm4/Zr4lCC8dwKCMYzsa5jFMDMQAzaBNy9W5VjB+KAmBjb40U7a/H6ao+Xo+9saIw==", "dev": true, "requires": { - "@babel/code-frame": "^7.0.0", - "@babel/generator": "^7.5.0", - "@babel/helper-function-name": "^7.1.0", - "@babel/helper-split-export-declaration": "^7.4.4", - "@babel/parser": "^7.5.0", - "@babel/types": "^7.5.0", + "@babel/code-frame": "^7.5.5", + "@babel/generator": "^7.7.4", + "@babel/helper-function-name": "^7.7.4", + "@babel/helper-split-export-declaration": "^7.7.4", + "@babel/parser": "^7.7.4", + "@babel/types": "^7.7.4", "debug": "^4.1.0", "globals": "^11.1.0", - "lodash": "^4.17.11" + "lodash": "^4.17.13" }, "dependencies": { "debug": { @@ -1005,39 +935,39 @@ } }, "@babel/types": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.5.0.tgz", - "integrity": "sha512-UFpDVqRABKsW01bvw7/wSUe56uy6RXM5+VJibVVAybDGxEW25jdwiFJEf7ASvSaC7sN7rbE/l3cLp2izav+CtQ==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.7.4.tgz", + "integrity": "sha512-cz5Ji23KCi4T+YIE/BolWosrJuSmoZeN1EFnRtBwF+KKLi8GG/Z2c2hOJJeCXPk4mwk4QFvTmwIodJowXgttRA==", "dev": true, "requires": { "esutils": "^2.0.2", - "lodash": "^4.17.11", + "lodash": "^4.17.13", "to-fast-properties": "^2.0.0" } }, "@nodelib/fs.scandir": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.1.tgz", - "integrity": "sha512-NT/skIZjgotDSiXs0WqYhgcuBKhUMgfekCmCGtkUAiLqZdOnrdjmZr9wRl3ll64J9NF79uZ4fk16Dx0yMc/Xbg==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz", + "integrity": "sha512-eGmwYQn3gxo4r7jdQnkrrN6bY478C3P+a/y72IJukF8LjB6ZHeB3c+Ehacj3sYeSmUXGlnA67/PmbM9CVwL7Dw==", "dev": true, "requires": { - "@nodelib/fs.stat": "2.0.1", + "@nodelib/fs.stat": "2.0.3", "run-parallel": "^1.1.9" } }, "@nodelib/fs.stat": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.1.tgz", - "integrity": "sha512-+RqhBlLn6YRBGOIoVYthsG0J9dfpO79eJyN7BYBkZJtfqrBwf2KK+rD/M/yjZR6WBmIhAgOV7S60eCgaSWtbFw==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.3.tgz", + "integrity": "sha512-bQBFruR2TAwoevBEd/NWMoAAtNGzTRgdrqnYCc7dhzfoNvqPzLyqlEQnzZ3kVnNrSp25iyxE00/3h2fqGAGArA==", "dev": true }, "@nodelib/fs.walk": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.2.tgz", - "integrity": "sha512-J/DR3+W12uCzAJkw7niXDcqcKBg6+5G5Q/ZpThpGNzAUz70eOR6RV4XnnSN01qHZiVl0eavoxJsBypQoKsV2QQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.4.tgz", + "integrity": "sha512-1V9XOY4rDW0rehzbrcqAmHnz8e7SKvX27gh8Gt2WgB0+pdzdiLV83p72kZPU+jvMbS1qU5mauP2iOvO8rhmurQ==", "dev": true, "requires": { - "@nodelib/fs.scandir": "2.1.1", + "@nodelib/fs.scandir": "2.1.3", "fastq": "^1.6.0" } }, @@ -1065,9 +995,9 @@ "dev": true }, "@types/node": { - "version": "12.6.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.6.3.tgz", - "integrity": "sha512-7TEYTQT1/6PP53NftXXabIZDaZfaoBdeBm8Md/i7zsWRoBe0YwOXguyK8vhHs8ehgB/w9U4K/6EWuTyp0W6nIA==", + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-13.1.2.tgz", + "integrity": "sha512-B8emQA1qeKerqd1dmIsQYnXi+mmAzTB7flExjmy5X1aVAKFNNNDubkavwR13kR6JnpeLp3aLoJhwn9trWPAyFQ==", "dev": true }, "JSONStream": { @@ -1087,35 +1017,38 @@ "dev": true }, "acorn": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.2.1.tgz", - "integrity": "sha512-JD0xT5FCRDNyjDda3Lrg/IxFscp9q4tiYtxE1/nOzlKCk7hIRuYjhq1kCNkbPjMRMZuFq20HNQn1I9k8Oj0E+Q==", - "dev": true - }, - "acorn-dynamic-import": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/acorn-dynamic-import/-/acorn-dynamic-import-4.0.0.tgz", - "integrity": "sha512-d3OEjQV4ROpoflsnUA8HozoIR504TFxNivYEUi6uwz0IYhBkTDXGuWlNdMtybRt3nqVx/L6XqMt0FxkXuWKZhw==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.0.tgz", + "integrity": "sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ==", "dev": true }, "acorn-node": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.7.0.tgz", - "integrity": "sha512-XhahLSsCB6X6CJbe+uNu3Mn9sJBNFxtBN9NLgAOQovfS6Kh0lDUtmlclhjn9CvEK7A7YyRU13PXlNcpSiLI9Yw==", + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", + "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==", "dev": true, "requires": { - "acorn": "^6.1.1", - "acorn-dynamic-import": "^4.0.0", - "acorn-walk": "^6.1.1", - "xtend": "^4.0.1" + "acorn": "^7.0.0", + "acorn-walk": "^7.0.0", + "xtend": "^4.0.2" } }, "acorn-walk": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-6.2.0.tgz", - "integrity": "sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.0.0.tgz", + "integrity": "sha512-7Bv1We7ZGuU79zZbb6rRqcpxo3OY+zrdtloZWoyD8fmGX+FeXRjE+iuGkZjSXLVovLzrsvMGMy0EkwA0E0umxg==", "dev": true }, + "aggregate-error": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.0.1.tgz", + "integrity": "sha512-quoaXsZ9/BLNae5yiNoUz+Nhkwz83GhWwtYFglcjEQB2NDHCIpApbqXxIFnm4Pq/Nvhrsq5sYJFyohrrxnTGAA==", + "dev": true, + "requires": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + } + }, "ajv": { "version": "6.10.2", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", @@ -1256,12 +1189,6 @@ "integrity": "sha1-p5SvDAWrF1KEbudTofIRoFugxE8=", "dev": true }, - "array-filter": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/array-filter/-/array-filter-0.0.1.tgz", - "integrity": "sha1-fajPLiZijtcygDWB/SH2fKzS7uw=", - "dev": true - }, "array-find-index": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", @@ -1303,18 +1230,6 @@ } } }, - "array-map": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/array-map/-/array-map-0.0.0.tgz", - "integrity": "sha1-iKK6tz0c97zVwbEYoAP2b2ZfpmI=", - "dev": true - }, - "array-reduce": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/array-reduce/-/array-reduce-0.0.0.tgz", - "integrity": "sha1-FziZ0//Rx9k4PkR5Ul2+J4yrXys=", - "dev": true - }, "array-slice": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz", @@ -1463,9 +1378,9 @@ "dev": true }, "aws4": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz", - "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.9.0.tgz", + "integrity": "sha512-Uvq6hVe90D0B2WEnUqtdgY1bATGz3mw33nH9Y+dmA+w5DHvUmBgkr5rM/KCHpCsiFNRUfokW/szpPPgMK2hm4A==", "dev": true }, "babel-plugin-dynamic-import-node": { @@ -1562,9 +1477,9 @@ } }, "base64-js": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz", - "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", + "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==", "dev": true }, "bcrypt-pbkdf": { @@ -1684,9 +1599,9 @@ } }, "browserify": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/browserify/-/browserify-16.3.0.tgz", - "integrity": "sha512-BWaaD7alyGZVEBBwSTYx4iJF5DswIGzK17o8ai9w4iKRbYpk3EOiprRHMRRA8DCZFmFeOdx7A385w2XdFvxWmg==", + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/browserify/-/browserify-16.5.0.tgz", + "integrity": "sha512-6bfI3cl76YLAnCZ75AGu/XPOsqUhRyc0F/olGIJeCxtfxF2HvPKEcmjU9M8oAPxl4uBY1U7Nry33Q6koV3f2iw==", "dev": true, "requires": { "JSONStream": "^1.0.3", @@ -1726,7 +1641,7 @@ "shasum": "^1.0.0", "shell-quote": "^1.6.1", "stream-browserify": "^2.0.0", - "stream-http": "^2.0.0", + "stream-http": "^3.0.0", "string_decoder": "^1.1.1", "subarg": "^1.0.0", "syntax-error": "^1.1.1", @@ -1737,14 +1652,6 @@ "util": "~0.10.1", "vm-browserify": "^1.0.0", "xtend": "^4.0.0" - }, - "dependencies": { - "punycode": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", - "dev": true - } } }, "browserify-aes": { @@ -1819,20 +1726,20 @@ } }, "browserslist": { - "version": "4.6.6", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.6.6.tgz", - "integrity": "sha512-D2Nk3W9JL9Fp/gIcWei8LrERCS+eXu9AM5cfXA8WEZ84lFks+ARnZ0q/R69m2SV3Wjma83QDDPxsNKXUwdIsyA==", + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.8.2.tgz", + "integrity": "sha512-+M4oeaTplPm/f1pXDw84YohEv7B1i/2Aisei8s4s6k3QsoSHa7i5sz8u/cGQkkatCPxMASKxPualR4wwYgVboA==", "dev": true, "requires": { - "caniuse-lite": "^1.0.30000984", - "electron-to-chromium": "^1.3.191", - "node-releases": "^1.1.25" + "caniuse-lite": "^1.0.30001015", + "electron-to-chromium": "^1.3.322", + "node-releases": "^1.1.42" } }, "buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.2.1.tgz", - "integrity": "sha512-c+Ko0loDaFfuPWiL02ls9Xd3GO3cPVmUobQ6t3rXNUk304u6hGq+8N/kFi+QEIKhzK3uwolVhLzszmfLmMLnqg==", + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.4.3.tgz", + "integrity": "sha512-zvj65TkFeIt3i6aj5bIvJDzjjQQGs4o/sNoezg1F1kYap9Nu2jcUdpwzRSJTHMMzG0H7bZkn4rNQpImhuxWX2A==", "dev": true, "requires": { "base64-js": "^1.0.2", @@ -1911,9 +1818,9 @@ } }, "caniuse-lite": { - "version": "1.0.30000984", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000984.tgz", - "integrity": "sha512-n5tKOjMaZ1fksIpQbjERuqCyfgec/m9pferkFQbLmWtqLUdmt12hNhjSwsmPdqeiG2NkITOQhr1VYIwWSAceiA==", + "version": "1.0.30001017", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001017.tgz", + "integrity": "sha512-EDnZyOJ6eYh6lHmCvCdHAFbfV4KJ9lSdfv4h/ppEhrU/Yudkl7jujwMZ1we6RX7DXqBfT04pVMQ4J+1wcTlsKA==", "dev": true }, "caseless": { @@ -1994,6 +1901,12 @@ } } }, + "clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true + }, "cliui": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", @@ -2159,13 +2072,10 @@ } }, "console-browserify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz", - "integrity": "sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA=", - "dev": true, - "requires": { - "date-now": "^0.1.4" - } + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz", + "integrity": "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==", + "dev": true }, "console-control-strings": { "version": "1.1.0", @@ -2204,37 +2114,24 @@ "is-plain-object": "^2.0.1" } }, - "core-js": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.1.4.tgz", - "integrity": "sha512-YNZN8lt82XIMLnLirj9MhKDFZHalwzzrL9YLt6eb0T5D0EDl4IQ90IGkua8mHbnxNrkj1d8hbdizMc0Qmg1WnQ==", - "dev": true - }, "core-js-compat": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.1.4.tgz", - "integrity": "sha512-Z5zbO9f1d0YrJdoaQhphVAnKPimX92D6z8lCGphH89MNRxlL1prI9ExJPqVwP0/kgkQCv8c4GJGT8X16yUncOg==", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.6.1.tgz", + "integrity": "sha512-2Tl1EuxZo94QS2VeH28Ebf5g3xbPZG/hj/N5HDDy4XMP/ImR0JIer/nggQRiMN91Q54JVkGbytf42wO29oXVHg==", "dev": true, "requires": { - "browserslist": "^4.6.2", - "core-js-pure": "3.1.4", - "semver": "^6.1.1" + "browserslist": "^4.8.2", + "semver": "7.0.0" }, "dependencies": { "semver": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.2.0.tgz", - "integrity": "sha512-jdFC1VdUGT/2Scgbimf7FSx9iJLXoqfglSF+gJeuNWVpiE37OIbc1jywR/GJyFdz3mnkz2/id0L0J/cr0izR5A==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", + "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", "dev": true } } }, - "core-js-pure": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.1.4.tgz", - "integrity": "sha512-uJ4Z7iPNwiu1foygbcZYJsJs1jiXrTTCvxfLDXNhI/I+NHbSIEyr548y4fcsCEyWY0XgfAG/qqaunJ1SThHenA==", - "dev": true - }, "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -2278,6 +2175,16 @@ "sha.js": "^2.4.8" } }, + "cross-spawn": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-3.0.1.tgz", + "integrity": "sha1-ElYDfsufDF9549bvE14wdwGEuYI=", + "dev": true, + "requires": { + "lru-cache": "^4.0.1", + "which": "^1.2.9" + } + }, "crypto-browserify": { "version": "3.12.0", "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz", @@ -2331,12 +2238,6 @@ "assert-plus": "^1.0.0" } }, - "date-now": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz", - "integrity": "sha1-6vQ5/U1ISK105cx9vvIAZyueNFs=", - "dev": true - }, "debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -2443,16 +2344,27 @@ "dev": true }, "del": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/del/-/del-5.0.0.tgz", - "integrity": "sha512-TfU3nUY0WDIhN18eq+pgpbLY9AfL5RfiE9czKaTSolc6aK7qASXfDErvYgjV1UqCR4sNXDoxO0/idPmhDUt2Sg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/del/-/del-5.1.0.tgz", + "integrity": "sha512-wH9xOVHnczo9jN2IW68BabcecVPxacIA3g/7z6vhSU/4stOKQzeCRK0yD0A24WiAAUJmmVpWqrERcTxnLo3AnA==", "dev": true, "requires": { - "globby": "^10.0.0", - "is-path-cwd": "^2.0.0", - "is-path-in-cwd": "^2.0.0", - "p-map": "^2.0.0", - "rimraf": "^2.6.3" + "globby": "^10.0.1", + "graceful-fs": "^4.2.2", + "is-glob": "^4.0.1", + "is-path-cwd": "^2.2.0", + "is-path-inside": "^3.0.1", + "p-map": "^3.0.0", + "rimraf": "^3.0.0", + "slash": "^3.0.0" + }, + "dependencies": { + "graceful-fs": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz", + "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==", + "dev": true + } } }, "delayed-stream": { @@ -2468,21 +2380,21 @@ "dev": true }, "deps-sort": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/deps-sort/-/deps-sort-2.0.0.tgz", - "integrity": "sha1-CRckkC6EZYJg65EHSMzNGvbiH7U=", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/deps-sort/-/deps-sort-2.0.1.tgz", + "integrity": "sha512-1orqXQr5po+3KI6kQb9A4jnXT1PBwggGl2d7Sq2xsnOeI9GPcE/tGcF9UiSZtZBM7MukY4cAh7MemS6tZYipfw==", "dev": true, "requires": { "JSONStream": "^1.0.3", - "shasum": "^1.0.0", + "shasum-object": "^1.0.0", "subarg": "^1.0.0", "through2": "^2.0.0" } }, "des.js": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.0.tgz", - "integrity": "sha1-wHTS4qpqipoH29YfmhXCzYPsjsw=", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.1.tgz", + "integrity": "sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA==", "dev": true, "requires": { "inherits": "^2.0.1", @@ -2582,15 +2494,15 @@ } }, "electron-to-chromium": { - "version": "1.3.191", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.191.tgz", - "integrity": "sha512-jasjtY5RUy/TOyiUYM2fb4BDaPZfm6CXRFeJDMfFsXYADGxUN49RBqtgB7EL2RmJXeIRUk9lM1U6A5yk2YJMPQ==", + "version": "1.3.322", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.322.tgz", + "integrity": "sha512-Tc8JQEfGQ1MzfSzI/bTlSr7btJv/FFO7Yh6tanqVmIWOuNCu6/D1MilIEgLtmWqIrsv+o4IjpLAhgMBr/ncNAA==", "dev": true }, "elliptic": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.0.tgz", - "integrity": "sha512-eFOJTMyCYb7xtE/caJ6JJu+bhi67WCYNbkGSknu20pmM8Ke/bqOfdnZWxyoGN26JgfxTbXrsCkEw4KheCT/KGg==", + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz", + "integrity": "sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw==", "dev": true, "requires": { "bn.js": "^4.4.0", @@ -2671,9 +2583,9 @@ "dev": true }, "esutils": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", - "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, "events": { @@ -2853,16 +2765,15 @@ "dev": true }, "fast-glob": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.0.4.tgz", - "integrity": "sha512-wkIbV6qg37xTJwqSsdnIphL1e+LaGz4AIQqr00mIubMaEhv1/HEmJ0uuCGZRNRUkZZmOB5mJKO0ZUTVq+SxMQg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.1.1.tgz", + "integrity": "sha512-nTCREpBY8w8r+boyFYAx21iL6faSsQynliPHM4Uf56SbkyohCNxpVPEH9xrF5TXKy+IsjkPUHDKiUkzBVRXn9g==", "dev": true, "requires": { - "@nodelib/fs.stat": "^2.0.1", - "@nodelib/fs.walk": "^1.2.1", - "glob-parent": "^5.0.0", - "is-glob": "^4.0.1", - "merge2": "^1.2.3", + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.0", + "merge2": "^1.3.0", "micromatch": "^4.0.2" }, "dependencies": { @@ -2885,9 +2796,9 @@ } }, "glob-parent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.0.0.tgz", - "integrity": "sha512-Z2RwiujPRGluePM6j699ktJYxmPpJKCfpGA13jz2hmFZC7gKetzrWvg5KN3+OsIFmydGyZ1AVwERCq1w/ZZwRg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.0.tgz", + "integrity": "sha512-qjtRgnIVmOfnKUE3NJAQEdk+lKrxfw8t5ke7SXtfMTHcjsBfOfWXCQfdb30zfDoZQ2IRSIiidmjtbHZPZ++Ihw==", "dev": true, "requires": { "is-glob": "^4.0.1" @@ -2921,9 +2832,15 @@ } }, "fast-json-stable-stringify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", - "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "fast-safe-stringify": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz", + "integrity": "sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA==", "dev": true }, "fastq": { @@ -3634,6 +3551,17 @@ "inherits": "~2.0.0", "mkdirp": ">=0.5 0", "rimraf": "2" + }, + "dependencies": { + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } } }, "function-bind": { @@ -3656,43 +3584,6 @@ "string-width": "^1.0.1", "strip-ansi": "^3.0.1", "wide-align": "^1.1.0" - }, - "dependencies": { - "ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", - "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", - "dev": true, - "requires": { - "number-is-nan": "^1.0.0" - } - }, - "string-width": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", - "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", - "dev": true, - "requires": { - "code-point-at": "^1.0.0", - "is-fullwidth-code-point": "^1.0.0", - "strip-ansi": "^3.0.0" - } - }, - "strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", - "dev": true, - "requires": { - "ansi-regex": "^2.0.0" - } - } } }, "gaze": { @@ -3851,9 +3742,9 @@ } }, "globule": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/globule/-/globule-1.2.1.tgz", - "integrity": "sha512-g7QtgWF4uYSL5/dn71WxubOrS7JVGCnFPEnoeChJmBnyR9Mw8nGoEwOgJL/RC2Te0WhbsEUCejfH8SZNJ+adYQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/globule/-/globule-1.3.0.tgz", + "integrity": "sha512-YlD4kdMqRCQHrhVdonet4TdRtv1/sZKepvoxNT4Nrhrp5HI8XFfc8kFlGlBn2myBo80aGp8Eft259mbcUJhgSg==", "dev": true, "requires": { "glob": "~7.1.1", @@ -3889,9 +3780,9 @@ } }, "gulp-babel": { - "version": "8.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gulp-babel/-/gulp-babel-8.0.0-beta.2.tgz", - "integrity": "sha512-GTC2PxAXWkp6u1fP+C5+kn5biQ0dKGhkOSSXvKAf3ykF0+R3tevmLm/zSIkc1+S7U1JwH3XTvuMwRL6LD+sEiw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gulp-babel/-/gulp-babel-8.0.0.tgz", + "integrity": "sha512-oomaIqDXxFkg7lbpBou/gnUkX51/Y/M2ZfSjL2hdqXTAlSWZcgZtd2o0cOH0r/eE8LWD0+Q/PsLsr2DKOoqToQ==", "dev": true, "requires": { "plugin-error": "^1.0.1", @@ -4011,14 +3902,6 @@ "dev": true, "requires": { "ansi-regex": "^2.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "dev": true - } } }, "has-flag": { @@ -4103,9 +3986,9 @@ } }, "hoist-non-react-statics": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.0.tgz", - "integrity": "sha512-0XsbTXxgiaCDYDIWFcwkmerZPSwywfUqYmwT4jzewKTQSWoE6FCMoUVOeBJWK3E/CrWbxRG3m5GzY4lnIwGRBA==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", + "integrity": "sha512-wbg3bpgA/ZqWrZuMOeJi8+SKMhr7X9TesL/rXMjTzh0p0JUBo3II8DHboYbuIXWRlttrUFxwcu/5kygrCw8fJw==", "requires": { "react-is": "^16.7.0" } @@ -4155,9 +4038,9 @@ "dev": true }, "ignore": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.2.tgz", - "integrity": "sha512-vdqWBp7MyzdmHkkRWV5nY+PfGRbYbahfuvsBCh277tq+w9zyNi7h5CYJCK0kmzti9kU+O/cB7sE8HvKv6aXAKQ==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.4.tgz", + "integrity": "sha512-MzbUSahkTW1u7JpKKjY7LCARd1fU5W2rLdxlM4kdkayuCwZImjkpluF9CM1aLewYJguPDqewLam18Y6AU69A8A==", "dev": true }, "in-publish": { @@ -4167,13 +4050,10 @@ "dev": true }, "indent-string": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz", - "integrity": "sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=", - "dev": true, - "requires": { - "repeating": "^2.0.0" - } + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true }, "inflight": { "version": "1.0.6", @@ -4355,12 +4235,6 @@ "number-is-nan": "^1.0.0" } }, - "is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", - "dev": true - }, "is-glob": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", @@ -4402,23 +4276,11 @@ "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", "dev": true }, - "is-path-in-cwd": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-2.1.0.tgz", - "integrity": "sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ==", - "dev": true, - "requires": { - "is-path-inside": "^2.1.0" - } - }, "is-path-inside": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-2.1.0.tgz", - "integrity": "sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg==", - "dev": true, - "requires": { - "path-is-inside": "^1.0.2" - } + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.2.tgz", + "integrity": "sha512-/2UGPSgmtqwo1ktx8NDHjuPwZWmHhO+gj0f93EkhLB5RgW9RZevWYYlIkS6zePc6U2WpOdQYIwHe9YC4DWEBVg==", + "dev": true }, "is-plain-object": { "version": "2.0.4", @@ -4563,9 +4425,9 @@ "dev": true }, "json5": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.0.tgz", - "integrity": "sha512-8Mh9h6xViijj36g7Dxi+Y4S6hNGV96vcJZr/SrlHh1LR/pEn/8j/+qIBbs44YKl69Lrfctp4QD+AdWLTMqEZAQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.1.tgz", + "integrity": "sha512-l+3HXD0GEI3huGq1njuqtzYK8OYJyXMkOLtQ53pjWh89tvWS2h6l+1zMkYWqlb57+SiQodKZyvMEFb2X+KrFhQ==", "dev": true, "requires": { "minimist": "^1.2.0" @@ -4736,6 +4598,16 @@ "signal-exit": "^3.0.0" } }, + "lru-cache": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", + "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "dev": true, + "requires": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, "make-dir": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", @@ -4841,9 +4713,9 @@ } }, "merge2": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.2.3.tgz", - "integrity": "sha512-gdUU1Fwj5ep4kplwcmftruWofEFt6lfpkkr3h860CXbAB9c3hGb55EOL2ali0Td5oebvW0E1+3Sr+Ur7XfKpRA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.3.0.tgz", + "integrity": "sha512-2j4DAdlBOkiSZIsaXk4mTE3sRS02yBHAtfy127xRV3bQUFqXkjHCHLW6Scv7DwNRbIWNHH8zpnz9zMaKXIdvYw==", "dev": true }, "micromatch": { @@ -4878,18 +4750,18 @@ } }, "mime-db": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz", - "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==", + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.42.0.tgz", + "integrity": "sha512-UbfJCR4UAVRNgMpfImz05smAXK7+c+ZntjaA26ANtkXLlOe947Aag5zdIcKQULAiF9Cq4WxBi9jUs5zkA84bYQ==", "dev": true }, "mime-types": { - "version": "2.1.24", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.24.tgz", - "integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==", + "version": "2.1.25", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.25.tgz", + "integrity": "sha512-5KhStqB5xpTAeGqKBAMgwaYMnQik7teQN4IAzC7npDv6kzeU6prfkR67bc87J1kWMPGkoaZSq1npmexMgkmEVg==", "dev": true, "requires": { - "mime-db": "1.40.0" + "mime-db": "1.42.0" } }, "minimalistic-assert": { @@ -4958,9 +4830,9 @@ } }, "module-deps": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/module-deps/-/module-deps-6.2.1.tgz", - "integrity": "sha512-UnEn6Ah36Tu4jFiBbJVUtt0h+iXqxpLqDvPS8nllbw5RZFmNJ1+Mz5BjYnM9ieH80zyxHkARGLnMIHlPK5bu6A==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/module-deps/-/module-deps-6.2.2.tgz", + "integrity": "sha512-a9y6yDv5u5I4A+IPHTnqFxcaKr4p50/zxTjcQJaX2ws9tN/W6J6YXnEKhqRyPhl494dkcxx951onSKVezmI+3w==", "dev": true, "requires": { "JSONStream": "^1.0.3", @@ -4968,7 +4840,7 @@ "cached-path-relative": "^1.0.2", "concat-stream": "~1.6.0", "defined": "^1.0.0", - "detective": "^5.0.2", + "detective": "^5.2.0", "duplexer2": "^0.1.2", "inherits": "^2.0.1", "parents": "^1.0.0", @@ -5043,6 +4915,15 @@ "which": "1" }, "dependencies": { + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, "semver": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz", @@ -5058,18 +4939,26 @@ "dev": true }, "node-releases": { - "version": "1.1.25", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.25.tgz", - "integrity": "sha512-fI5BXuk83lKEoZDdH3gRhtsNgh05/wZacuXkgbiYkceE7+QIMXOg98n9ZV7mz27B+kFHnqHcUpscZZlGRSmTpQ==", + "version": "1.1.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.44.tgz", + "integrity": "sha512-NwbdvJyR7nrcGrXvKAvzc5raj/NkoJudkarh2yIpJ4t0NH4aqjUDz/486P+ynIW5eokKOfzGNRdYoLfBlomruw==", "dev": true, "requires": { - "semver": "^5.3.0" + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } } }, "node-sass": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/node-sass/-/node-sass-4.12.0.tgz", - "integrity": "sha512-A1Iv4oN+Iel6EPv77/HddXErL2a+gZ4uBeZUy+a8O35CFYTXhgA8MgLCWBtwpGZdCvTvQ9d+bQxX/QC36GDPpQ==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/node-sass/-/node-sass-4.13.0.tgz", + "integrity": "sha512-W1XBrvoJ1dy7VsvTAS5q1V45lREbTlZQqFbiHb3R3OTTCma0XBtuG6xZ6Z4506nR4lmHPTqVRwxT6KgtWC97CA==", "dev": true, "requires": { "async-foreach": "^0.1.3", @@ -5079,7 +4968,7 @@ "get-stdin": "^4.0.1", "glob": "^7.0.3", "in-publish": "^2.0.0", - "lodash": "^4.17.11", + "lodash": "^4.17.15", "meow": "^3.7.0", "mkdirp": "^0.5.1", "nan": "^2.13.2", @@ -5091,12 +4980,6 @@ "true-case-path": "^1.0.2" }, "dependencies": { - "ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "dev": true - }, "ansi-styles": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", @@ -5116,46 +4999,11 @@ "supports-color": "^2.0.0" } }, - "cross-spawn": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-3.0.1.tgz", - "integrity": "sha1-ElYDfsufDF9549bvE14wdwGEuYI=", - "dev": true, - "requires": { - "lru-cache": "^4.0.1", - "which": "^1.2.9" - } - }, - "lru-cache": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", - "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", - "dev": true, - "requires": { - "pseudomap": "^1.0.2", - "yallist": "^2.1.2" - } - }, - "strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", - "dev": true, - "requires": { - "ansi-regex": "^2.0.0" - } - }, "supports-color": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", "dev": true - }, - "yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", - "dev": true } } }, @@ -5382,9 +5230,9 @@ } }, "p-limit": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.0.tgz", - "integrity": "sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.1.tgz", + "integrity": "sha512-85Tk+90UCVWvbDavCLKPOLC9vvY8OwEX/RtKF+/1OADJMVlFfEHOiMTPVyxg7mk/dKa+ipdHm0OUkTvCpMTuwg==", "dev": true, "requires": { "p-try": "^2.0.0" @@ -5400,10 +5248,13 @@ } }, "p-map": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", - "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", - "dev": true + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "requires": { + "aggregate-error": "^3.0.0" + } }, "p-try": { "version": "2.2.0", @@ -5427,9 +5278,9 @@ } }, "parse-asn1": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.4.tgz", - "integrity": "sha512-Qs5duJcuvNExRfFZ99HDD3z4mAi3r9Wl/FOjEOijlxwCZs7E7mW2vjTpgQ4J8LpTF8x5v+1Vn5UQFejmWT11aw==", + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.5.tgz", + "integrity": "sha512-jkMYn1dcJqF6d5CpU689bq7w/b5ALS9ROVSpQDPrZsqqesUJii9qutvoT5ltGedNXMO2e16YUWIghG9KxaViTQ==", "dev": true, "requires": { "asn1.js": "^4.0.0", @@ -5502,12 +5353,6 @@ "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", "dev": true }, - "path-is-inside": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", - "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=", - "dev": true - }, "path-parse": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", @@ -5574,9 +5419,9 @@ "dev": true }, "picomatch": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.0.7.tgz", - "integrity": "sha512-oLHIdio3tZ0qH76NybpeneBhYVj0QFTfXEFTc/B3zKQspYfYYkWYgFsmzo+4kvId/bQRcNkVeguI3y+CD22BtA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.1.1.tgz", + "integrity": "sha512-OYMyqkKzK7blWO/+XZYP6w8hH0LDvkBvdvKukti+7kqYFCiEAk+gI3DWnryapc0Dau05ugGTy0foQ6mqn4AHYA==", "dev": true }, "pify": { @@ -5637,9 +5482,9 @@ "dev": true }, "prettier": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.18.2.tgz", - "integrity": "sha512-OeHeMc0JhFE9idD4ZdtNibzY0+TPHSpSSb9h8FqtP+YnoZZ1sl8Vc9b1sasjfymH3SonAF4QcA2+mzHPhMvIiw==", + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz", + "integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==", "dev": true }, "pretty-hrtime": { @@ -5683,9 +5528,9 @@ "dev": true }, "psl": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.2.0.tgz", - "integrity": "sha512-GEn74ZffufCmkDDLNcl3uuyF/aSD6exEyh1v/ZSdAomB82t6G9hzJVRx0jBmLDW+VfZqks3aScmMw9DszwUalA==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.7.0.tgz", + "integrity": "sha512-5NsSEDv8zY70ScRnOTn7bK7eanl2MvFrOrS/R6x+dBt5g1ghnj9Zv90kO8GwT8gxcu2ANyFprnFYB85IogIJOQ==", "dev": true }, "public-encrypt": { @@ -5724,9 +5569,9 @@ } }, "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", "dev": true }, "qs": { @@ -5767,45 +5612,44 @@ } }, "react": { - "version": "16.8.6", - "resolved": "https://registry.npmjs.org/react/-/react-16.8.6.tgz", - "integrity": "sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw==", + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/react/-/react-16.12.0.tgz", + "integrity": "sha512-fglqy3k5E+81pA8s+7K0/T3DBCF0ZDOher1elBFzF7O6arXJgzyu/FW+COxFvAWXJoJN9KIZbT2LXlukwphYTA==", "dev": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", - "prop-types": "^15.6.2", - "scheduler": "^0.13.6" + "prop-types": "^15.6.2" } }, "react-dom": { - "version": "16.8.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.8.6.tgz", - "integrity": "sha512-1nL7PIq9LTL3fthPqwkvr2zY7phIPjYrT0jp4HjyEQrEROnw4dG41VVwi/wfoCneoleqrNX7iAD+pXebJZwrwA==", + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.12.0.tgz", + "integrity": "sha512-LMxFfAGrcS3kETtQaCkTKjMiifahaMySFDn71fZUNpPHZQEzmk/GiAeIT8JSOrHB23fnuCOMruL2a8NYlw+8Gw==", "dev": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", "prop-types": "^15.6.2", - "scheduler": "^0.13.6" + "scheduler": "^0.18.0" } }, "react-is": { - "version": "16.8.6", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz", - "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==" + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.12.0.tgz", + "integrity": "sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q==" }, "react-redux": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.1.0.tgz", - "integrity": "sha512-hyu/PoFK3vZgdLTg9ozbt7WF3GgX5+Yn3pZm5/96/o4UueXA+zj08aiSC9Mfj2WtD1bvpIb3C5yvskzZySzzaw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.1.3.tgz", + "integrity": "sha512-uI1wca+ECG9RoVkWQFF4jDMqmaw0/qnvaSvOoL/GA4dNxf6LoV8sUAcNDvE5NWKs4hFpn0t6wswNQnY3f7HT3w==", "requires": { - "@babel/runtime": "^7.4.5", + "@babel/runtime": "^7.5.5", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4", "loose-envify": "^1.4.0", "prop-types": "^15.7.2", - "react-is": "^16.8.6" + "react-is": "^16.9.0" } }, "read-only-stream": { @@ -5902,12 +5746,23 @@ "requires": { "indent-string": "^2.1.0", "strip-indent": "^1.0.1" + }, + "dependencies": { + "indent-string": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz", + "integrity": "sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=", + "dev": true, + "requires": { + "repeating": "^2.0.0" + } + } } }, "redux": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/redux/-/redux-4.0.4.tgz", - "integrity": "sha512-vKv4WdiJxOWKxK0yRoaK3Y4pxxB0ilzVx6dszU2W8wLxlb2yikRph4iV/ymtdJ6ZxpBLFbyrxklnT5yBbQSl3Q==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz", + "integrity": "sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w==", "requires": { "loose-envify": "^1.4.0", "symbol-observable": "^1.2.0" @@ -5947,9 +5802,9 @@ "integrity": "sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw==" }, "regenerator-transform": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.14.0.tgz", - "integrity": "sha512-rtOelq4Cawlbmq9xuMR5gdFmv7ku/sFoB7sRiywx7aq53bc52b4j6zvH7Te1Vt/X2YveDKnCGUbioieU7FEL3w==", + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.14.1.tgz", + "integrity": "sha512-flVuee02C3FKRISbxhXl9mGzdbWUVHubl1SMaknjxkFB1/iqpJhArQUvRxOOPEc/9tAiX0BaQ28FJH10E4isSQ==", "dev": true, "requires": { "private": "^0.1.6" @@ -5965,20 +5820,14 @@ "safe-regex": "^1.1.0" } }, - "regexp-tree": { - "version": "0.1.11", - "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.11.tgz", - "integrity": "sha512-7/l/DgapVVDzZobwMCCgMlqiqyLFJ0cduo/j+3BcDJIB+yJdsYCfKuI3l/04NV+H/rfNRdPIDbXNZHM9XvQatg==", - "dev": true - }, "regexpu-core": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-4.5.4.tgz", - "integrity": "sha512-BtizvGtFQKGPUcTy56o3nk1bGRp4SZOTYrDtGNlqCQufptV5IkkLN6Emw+yunAJjzf+C9FQFtvq7IoA3+oMYHQ==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-4.6.0.tgz", + "integrity": "sha512-YlVaefl8P5BnFYOITTNzDvan1ulLOiXJzCNZxduTIosN17b87h3bvG9yHMoHaRuo88H4mQ06Aodj5VtYGGGiTg==", "dev": true, "requires": { "regenerate": "^1.4.0", - "regenerate-unicode-properties": "^8.0.2", + "regenerate-unicode-properties": "^8.1.0", "regjsgen": "^0.5.0", "regjsparser": "^0.6.0", "unicode-match-property-ecmascript": "^1.0.4", @@ -5986,15 +5835,15 @@ } }, "regjsgen": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.5.0.tgz", - "integrity": "sha512-RnIrLhrXCX5ow/E5/Mh2O4e/oa1/jW0eaBKTSy3LaCj+M3Bqvm97GWDp2yUtzIs4LEn65zR2yiYGFqb2ApnzDA==", + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.5.1.tgz", + "integrity": "sha512-5qxzGZjDs9w4tzT3TPhCJqWdCc3RLYwy9J2NB0nm5Lz+S273lvWcpjaTGHsT1dc6Hhfq41uSEOw8wBmxrKOuyg==", "dev": true }, "regjsparser": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.6.0.tgz", - "integrity": "sha512-RQ7YyokLiQBomUJuUG8iGVvkgOLxwyZM8k6d3q5SAXpg4r5TZJZigKFvC6PpD+qQ98bCDC5YelPeA3EucDoNeQ==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.6.2.tgz", + "integrity": "sha512-E9ghzUtoLwDekPT0DYCp+c4h+bvuUpe6rRHCTYn6eGoqj1LgKXxT6I0Il4WbjhQkOghzi/V+y03bPKvbllL93Q==", "dev": true, "requires": { "jsesc": "~0.5.0" @@ -6160,9 +6009,9 @@ "dev": true }, "rimraf": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", - "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.0.tgz", + "integrity": "sha512-NDGVxTsjqfunkds7CqsOiEnxln4Bo7Nddl3XhS4pXg5OzwkLqJ971ZVAAnB+DDLnF76N+VnDEiBHaVV8I06SUg==", "dev": true, "requires": { "glob": "^7.1.3" @@ -6215,154 +6064,12 @@ "lodash": "^4.0.0", "scss-tokenizer": "^0.2.3", "yargs": "^7.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "dev": true - }, - "camelcase": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz", - "integrity": "sha1-MvxLn82vhF/N9+c7uXysImHwqwo=", - "dev": true - }, - "cliui": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", - "integrity": "sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=", - "dev": true, - "requires": { - "string-width": "^1.0.1", - "strip-ansi": "^3.0.1", - "wrap-ansi": "^2.0.0" - } - }, - "get-caller-file": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", - "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==", - "dev": true - }, - "invert-kv": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", - "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", - "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", - "dev": true, - "requires": { - "number-is-nan": "^1.0.0" - } - }, - "lcid": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", - "integrity": "sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU=", - "dev": true, - "requires": { - "invert-kv": "^1.0.0" - } - }, - "os-locale": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", - "integrity": "sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=", - "dev": true, - "requires": { - "lcid": "^1.0.0" - } - }, - "require-main-filename": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz", - "integrity": "sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=", - "dev": true - }, - "string-width": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", - "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", - "dev": true, - "requires": { - "code-point-at": "^1.0.0", - "is-fullwidth-code-point": "^1.0.0", - "strip-ansi": "^3.0.0" - } - }, - "strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", - "dev": true, - "requires": { - "ansi-regex": "^2.0.0" - } - }, - "which-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-1.0.0.tgz", - "integrity": "sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8=", - "dev": true - }, - "wrap-ansi": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", - "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", - "dev": true, - "requires": { - "string-width": "^1.0.1", - "strip-ansi": "^3.0.1" - } - }, - "y18n": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz", - "integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE=", - "dev": true - }, - "yargs": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-7.1.0.tgz", - "integrity": "sha1-a6MY6xaWFyf10oT46gA+jWFU0Mg=", - "dev": true, - "requires": { - "camelcase": "^3.0.0", - "cliui": "^3.2.0", - "decamelize": "^1.1.1", - "get-caller-file": "^1.0.1", - "os-locale": "^1.4.0", - "read-pkg-up": "^1.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^1.0.1", - "set-blocking": "^2.0.0", - "string-width": "^1.0.2", - "which-module": "^1.0.0", - "y18n": "^3.2.1", - "yargs-parser": "^5.0.0" - } - }, - "yargs-parser": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-5.0.0.tgz", - "integrity": "sha1-J17PDX/+Bcd+ZOfIbkzZS/DhIoo=", - "dev": true, - "requires": { - "camelcase": "^3.0.0" - } - } } }, "scheduler": { - "version": "0.13.6", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.6.tgz", - "integrity": "sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ==", + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.18.0.tgz", + "integrity": "sha512-agTSHR1Nbfi6ulI0kYNK0203joW2Y5W4po4l+v03tOoiJKpTBbxpNhWDvqc/4IcOw+KLmSiQLTasZ4cab2/UWQ==", "dev": true, "requires": { "loose-envify": "^1.1.0", @@ -6454,18 +6161,21 @@ "sha.js": "~2.4.4" } }, - "shell-quote": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.6.1.tgz", - "integrity": "sha1-9HgZSczkAmlxJ0MOo7PFR29IF2c=", + "shasum-object": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shasum-object/-/shasum-object-1.0.0.tgz", + "integrity": "sha512-Iqo5rp/3xVi6M4YheapzZhhGPVs0yZwHj7wvwQ1B9z8H6zk+FEnI7y3Teq7qwnekfEhu8WmG2z0z4iWZaxLWVg==", "dev": true, "requires": { - "array-filter": "~0.0.0", - "array-map": "~0.0.0", - "array-reduce": "~0.0.0", - "jsonify": "~0.0.0" + "fast-safe-stringify": "^2.0.7" } }, + "shell-quote": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.2.tgz", + "integrity": "sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg==", + "dev": true + }, "signal-exit": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", @@ -6611,9 +6321,9 @@ } }, "source-map-support": { - "version": "0.5.12", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.12.tgz", - "integrity": "sha512-4h2Pbvyy15EE02G+JOZpUCmqWJuqrs+sEkzewTm++BPi7Hvn/HwcqLAcNxYAyI0x13CpPPn+kMjl+hplXMHITQ==", + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.16.tgz", + "integrity": "sha512-efyLRJDr68D9hBBNIPWFjhpFzURh+KJykQwvMyW5UiZzYwoF6l4YMMDIJJEyFWxWCqfyxLzz6tSfUFR+kXXsVQ==", "dev": true, "requires": { "buffer-from": "^1.0.0", @@ -6761,16 +6471,28 @@ "dev": true }, "stream-http": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-2.8.3.tgz", - "integrity": "sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-3.1.0.tgz", + "integrity": "sha512-cuB6RgO7BqC4FBYzmnvhob5Do3wIdIsXAgGycHJnW+981gHqoYcYz9lqjJrk8WXRddbwPuqPYRl+bag6mYv4lw==", "dev": true, "requires": { "builtin-status-codes": "^3.0.0", "inherits": "^2.0.1", - "readable-stream": "^2.3.6", - "to-arraybuffer": "^1.0.0", + "readable-stream": "^3.0.6", "xtend": "^4.0.0" + }, + "dependencies": { + "readable-stream": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz", + "integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } } }, "stream-shift": { @@ -6951,12 +6673,6 @@ "is-negated-glob": "^1.0.0" } }, - "to-arraybuffer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", - "integrity": "sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M=", - "dev": true - }, "to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -7022,14 +6738,6 @@ "requires": { "psl": "^1.1.24", "punycode": "^1.4.1" - }, - "dependencies": { - "punycode": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", - "dev": true - } } }, "trim-newlines": { @@ -7038,12 +6746,6 @@ "integrity": "sha1-WIeWa7WCpFA6QetST301ARgVphM=", "dev": true }, - "trim-right": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz", - "integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=", - "dev": true - }, "true-case-path": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/true-case-path/-/true-case-path-1.0.3.tgz", @@ -7237,6 +6939,14 @@ "dev": true, "requires": { "punycode": "^2.1.0" + }, + "dependencies": { + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true + } } }, "urix": { @@ -7293,9 +7003,9 @@ "dev": true }, "uuid": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", - "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz", + "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==", "dev": true }, "v8flags": { @@ -7418,9 +7128,9 @@ } }, "vm-browserify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.0.tgz", - "integrity": "sha512-iq+S7vZJE60yejDYM0ek6zg308+UZsdtPExWP9VZoCFCz1zkJoXFnAX7aZfd/ZwrkidzdUZL0C/ryW+JwAiIGw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", + "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==", "dev": true }, "which": { @@ -7445,33 +7155,6 @@ "dev": true, "requires": { "string-width": "^1.0.2 || 2" - }, - "dependencies": { - "ansi-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", - "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", - "dev": true - }, - "string-width": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", - "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", - "dev": true, - "requires": { - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^4.0.0" - } - }, - "strip-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", - "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", - "dev": true, - "requires": { - "ansi-regex": "^3.0.0" - } - } } }, "wrap-ansi": { @@ -7502,6 +7185,12 @@ "integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE=", "dev": true }, + "yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", + "dev": true + }, "yargs": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-7.1.0.tgz", diff --git a/package.json b/package.json index 5eca7dd..f19d3fb 100644 --- a/package.json +++ b/package.json @@ -16,34 +16,34 @@ "dependencies": { "js-cookie": "^2.2.1", "lodash": "^4.17.15", - "react-redux": "^7.1.0", - "redux": "^4.0.4", + "react-redux": "^7.1.3", + "redux": "^4.0.5", "redux-logger": "^3.0.6", "redux-thunk": "^2.3.0" }, "devDependencies": { - "@babel/core": "^7.5.4", - "@babel/plugin-proposal-class-properties": "^7.5.5", - "@babel/plugin-proposal-function-bind": "^7.2.0", - "@babel/plugin-syntax-dynamic-import": "^7.2.0", - "@babel/plugin-syntax-function-bind": "^7.2.0", - "@babel/plugin-transform-react-jsx": "^7.3.0", - "@babel/plugin-transform-runtime": "^7.5.5", - "@babel/preset-env": "^7.5.4", - "@babel/register": "^7.4.4", - "@babel/runtime": "^7.5.5", + "@babel/core": "^7.7.7", + "@babel/plugin-proposal-class-properties": "^7.7.4", + "@babel/plugin-proposal-function-bind": "^7.7.4", + "@babel/plugin-syntax-dynamic-import": "^7.7.4", + "@babel/plugin-syntax-function-bind": "^7.7.4", + "@babel/plugin-transform-react-jsx": "^7.7.7", + "@babel/plugin-transform-runtime": "^7.7.6", + "@babel/preset-env": "^7.7.7", + "@babel/register": "^7.7.7", + "@babel/runtime": "^7.7.7", "babelify": "^10.0.0", - "browserify": "^16.3.0", - "del": "^5.0.0", + "browserify": "^16.5.0", + "del": "^5.1.0", "gulp": "^4.0.2", - "gulp-babel": "^8.0.0-beta.2", + "gulp-babel": "^8.0.0", "gulp-cli": "^2.2.0", "gulp-concat": "^2.6.1", "gulp-sass": "^4.0.2", - "node-sass": "^4.12.0", - "prettier": "^1.18.2", - "react": "^16.8.6", - "react-dom": "^16.8.6", + "node-sass": "^4.13.0", + "prettier": "^1.19.1", + "react": "^16.12.0", + "react-dom": "^16.12.0", "vinyl-buffer": "^1.0.1", "vinyl-source-stream": "^2.0.0" } diff --git a/src/newsreader/fixtures/default-fixture.json b/src/newsreader/fixtures/default-fixture.json index ed3c3be..e0de28f 100644 --- a/src/newsreader/fixtures/default-fixture.json +++ b/src/newsreader/fixtures/default-fixture.json @@ -1,31 +1,4 @@ [ -{ - "model": "django_celery_beat.periodictask", - "pk": 2, - "fields": { - "name": "celery.backend_cleanup", - "task": "celery.backend_cleanup", - "interval": null, - "crontab": 1, - "solar": null, - "clocked": null, - "args": "[]", - "kwargs": "{}", - "queue": null, - "exchange": null, - "routing_key": null, - "headers": "{}", - "priority": null, - "expires": null, - "one_off": false, - "start_time": null, - "enabled": true, - "last_run_at": "2019-11-29T04:00:00.000Z", - "total_run_count": 17, - "date_changed": "2019-11-29T04:02:15.258Z", - "description": "" - } -}, { "model": "django_celery_beat.periodictask", "pk": 10, From 68534cd5413c6931dd217d3dd6bfa3da496238a3 Mon Sep 17 00:00:00 2001 From: Sonny Date: Wed, 1 Jan 2020 21:39:07 +0100 Subject: [PATCH 034/422] Remove deprecated npm options --- .gitlab-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c4ac909..cb9600d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -11,7 +11,7 @@ javascript build: paths: - node_modules/ before_script: - - npm install --dev + - npm install --only=dev script: - npx gulp @@ -45,7 +45,7 @@ javascript linting: paths: - node_modules/ before_script: - - npm install --dev + - npm install --only=dev script: - npm run lint From 28620eab2986ef8fcd3c18aae0d9b3092250a3e5 Mon Sep 17 00:00:00 2001 From: Sonny Date: Wed, 1 Jan 2020 21:46:35 +0100 Subject: [PATCH 035/422] Rerun prettier --- src/newsreader/js/pages/homepage/App.js | 5 +---- src/newsreader/js/pages/homepage/components/PostModal.js | 5 +---- .../js/pages/homepage/components/feedlist/FeedList.js | 5 +---- .../js/pages/homepage/components/feedlist/PostItem.js | 5 +---- .../js/pages/homepage/components/sidebar/CategoryItem.js | 5 +---- .../js/pages/homepage/components/sidebar/ReadButton.js | 5 +---- .../js/pages/homepage/components/sidebar/RuleItem.js | 5 +---- 7 files changed, 7 insertions(+), 28 deletions(-) diff --git a/src/newsreader/js/pages/homepage/App.js b/src/newsreader/js/pages/homepage/App.js index fa9bc2c..9f88e46 100644 --- a/src/newsreader/js/pages/homepage/App.js +++ b/src/newsreader/js/pages/homepage/App.js @@ -57,7 +57,4 @@ const mapDispatchToProps = dispatch => ({ fetchCategories: () => dispatch(fetchCategories()), }); -export default connect( - mapStateToProps, - mapDispatchToProps -)(App); +export default connect(mapStateToProps, mapDispatchToProps)(App); diff --git a/src/newsreader/js/pages/homepage/components/PostModal.js b/src/newsreader/js/pages/homepage/components/PostModal.js index 524a217..75650fb 100644 --- a/src/newsreader/js/pages/homepage/components/PostModal.js +++ b/src/newsreader/js/pages/homepage/components/PostModal.js @@ -78,7 +78,4 @@ const mapDispatchToProps = dispatch => ({ markPostRead: (post, token) => dispatch(markPostRead(post, token)), }); -export default connect( - null, - mapDispatchToProps -)(PostModal); +export default connect(null, mapDispatchToProps)(PostModal); diff --git a/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js b/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js index 36966c8..418e028 100644 --- a/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js +++ b/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js @@ -89,7 +89,4 @@ const mapDispatchToProps = dispatch => ({ fetchPostsBySection: (rule, page = false) => dispatch(fetchPostsBySection(rule, page)), }); -export default connect( - mapStateToProps, - mapDispatchToProps -)(FeedList); +export default connect(mapStateToProps, mapDispatchToProps)(FeedList); diff --git a/src/newsreader/js/pages/homepage/components/feedlist/PostItem.js b/src/newsreader/js/pages/homepage/components/feedlist/PostItem.js index e226e9b..865af41 100644 --- a/src/newsreader/js/pages/homepage/components/feedlist/PostItem.js +++ b/src/newsreader/js/pages/homepage/components/feedlist/PostItem.js @@ -44,7 +44,4 @@ const mapDispatchToProps = dispatch => ({ selectPost: post => dispatch(selectPost(post)), }); -export default connect( - null, - mapDispatchToProps -)(PostItem); +export default connect(null, mapDispatchToProps)(PostItem); diff --git a/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js b/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js index 36279ba..6413406 100644 --- a/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js +++ b/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js @@ -61,7 +61,4 @@ const mapDispatchToProps = dispatch => ({ fetchCategory: category => dispatch(fetchCategory(category)), }); -export default connect( - null, - mapDispatchToProps -)(CategoryItem); +export default connect(null, mapDispatchToProps)(CategoryItem); diff --git a/src/newsreader/js/pages/homepage/components/sidebar/ReadButton.js b/src/newsreader/js/pages/homepage/components/sidebar/ReadButton.js index 45e3781..3d33fc0 100644 --- a/src/newsreader/js/pages/homepage/components/sidebar/ReadButton.js +++ b/src/newsreader/js/pages/homepage/components/sidebar/ReadButton.js @@ -30,7 +30,4 @@ const mapDispatchToProps = dispatch => ({ const mapStateToProps = state => ({ selected: state.selected.item }); -export default connect( - mapStateToProps, - mapDispatchToProps -)(ReadButton); +export default connect(mapStateToProps, mapDispatchToProps)(ReadButton); diff --git a/src/newsreader/js/pages/homepage/components/sidebar/RuleItem.js b/src/newsreader/js/pages/homepage/components/sidebar/RuleItem.js index d93eb26..5f70cfd 100644 --- a/src/newsreader/js/pages/homepage/components/sidebar/RuleItem.js +++ b/src/newsreader/js/pages/homepage/components/sidebar/RuleItem.js @@ -45,7 +45,4 @@ const mapDispatchToProps = dispatch => ({ fetchRule: rule => dispatch(fetchRule(rule)), }); -export default connect( - null, - mapDispatchToProps -)(RuleItem); +export default connect(null, mapDispatchToProps)(RuleItem); From 06ce82bf0c5d42ead5ab8d171da72e9c77aa1cde Mon Sep 17 00:00:00 2001 From: sonny Date: Thu, 2 Jan 2020 20:15:35 +0100 Subject: [PATCH 036/422] Merge category view tests into single file --- ...t_category_updateview.py => test_views.py} | 59 +++++++++++++++++-- .../news/core/tests/views/__init__.py | 0 .../core/tests/views/category/__init__.py | 0 .../category/test_category_createview.py | 58 ------------------ 4 files changed, 55 insertions(+), 62 deletions(-) rename src/newsreader/news/core/tests/{views/category/test_category_updateview.py => test_views.py} (73%) delete mode 100644 src/newsreader/news/core/tests/views/__init__.py delete mode 100644 src/newsreader/news/core/tests/views/category/__init__.py delete mode 100644 src/newsreader/news/core/tests/views/category/test_category_createview.py diff --git a/src/newsreader/news/core/tests/views/category/test_category_updateview.py b/src/newsreader/news/core/tests/test_views.py similarity index 73% rename from src/newsreader/news/core/tests/views/category/test_category_updateview.py rename to src/newsreader/news/core/tests/test_views.py index 23fa769..47381d2 100644 --- a/src/newsreader/news/core/tests/views/category/test_category_updateview.py +++ b/src/newsreader/news/core/tests/test_views.py @@ -3,22 +3,73 @@ from django.urls import reverse from newsreader.accounts.tests.factories import UserFactory from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.core.models import Category from newsreader.news.core.tests.factories import CategoryFactory -class CategoryUpdateViewTestCase(TestCase): +class CategoryViewTestCase: def setUp(self): self.user = UserFactory(password="test") self.client.login(email=self.user.email, password="test") - self.category = CategoryFactory(name="category", user=self.user) - self.url = reverse("category-update", args=[self.category.pk]) - def test_simple(self): response = self.client.get(self.url) self.assertEquals(response.status_code, 200) + +class CategoryCreateViewTestCase(CategoryViewTestCase, TestCase): + def setUp(self): + super().setUp() + + self.url = reverse("category-create") + + def test_creation(self): + rules = CollectionRuleFactory.create_batch(size=4, user=self.user) + + data = {"name": "new-category", "rules": [rule.pk for rule in rules]} + response = self.client.post(self.url, data) + + self.assertEquals(response.status_code, 302) + + category = Category.objects.get(name="new-category") + + self.assertCountEqual(category.rule_ids, [rule.pk for rule in rules]) + + def test_collection_rules_only_from_user(self): + other_user = UserFactory() + other_rules = CollectionRuleFactory.create_batch(size=4, user=other_user) + + response = self.client.get(self.url) + + for rule in other_rules: + self.assertNotContains(response, rule.name) + + def test_creation_with_other_user_rules(self): + other_user = UserFactory() + other_rules = CollectionRuleFactory.create_batch( + size=4, user=other_user, category=None + ) + + user_rules = CollectionRuleFactory.create_batch( + size=3, user=self.user, category=None + ) + + data = {"name": "new-category", "rules": [rule.pk for rule in other_rules]} + + response = self.client.post(self.url, data) + + self.assertContains(response, "not one of the available choices") + self.assertEquals(Category.objects.count(), 0) + + +class CategoryUpdateViewTestCase(CategoryViewTestCase, TestCase): + def setUp(self): + super().setUp() + + self.category = CategoryFactory(name="category", user=self.user) + self.url = reverse("category-update", args=[self.category.pk]) + def test_name_change(self): data = {"name": "durp"} self.client.post(self.url, data) diff --git a/src/newsreader/news/core/tests/views/__init__.py b/src/newsreader/news/core/tests/views/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/newsreader/news/core/tests/views/category/__init__.py b/src/newsreader/news/core/tests/views/category/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/newsreader/news/core/tests/views/category/test_category_createview.py b/src/newsreader/news/core/tests/views/category/test_category_createview.py deleted file mode 100644 index ce25e20..0000000 --- a/src/newsreader/news/core/tests/views/category/test_category_createview.py +++ /dev/null @@ -1,58 +0,0 @@ -from django.test import Client, TestCase -from django.urls import reverse - -from newsreader.accounts.tests.factories import UserFactory -from newsreader.news.collection.tests.factories import CollectionRuleFactory -from newsreader.news.core.models import Category -from newsreader.news.core.tests.factories import CategoryFactory - - -class CategoryCreateViewTestCase(TestCase): - def setUp(self): - self.user = UserFactory(password="test") - self.client.login(email=self.user.email, password="test") - - self.url = reverse("category-create") - - def test_simple(self): - response = self.client.get(self.url) - - self.assertEquals(response.status_code, 200) - - def test_creation(self): - rules = CollectionRuleFactory.create_batch(size=4, user=self.user) - - data = {"name": "new-category", "rules": [rule.pk for rule in rules]} - response = self.client.post(self.url, data) - - self.assertEquals(response.status_code, 302) - - category = Category.objects.get(name="new-category") - - self.assertCountEqual(category.rule_ids, [rule.pk for rule in rules]) - - def test_collection_rules_only_from_user(self): - other_user = UserFactory() - other_rules = CollectionRuleFactory.create_batch(size=4, user=other_user) - - response = self.client.get(self.url) - - for rule in other_rules: - self.assertNotContains(response, rule.name) - - def test_creation_with_other_user_rules(self): - other_user = UserFactory() - other_rules = CollectionRuleFactory.create_batch( - size=4, user=other_user, category=None - ) - - user_rules = CollectionRuleFactory.create_batch( - size=3, user=self.user, category=None - ) - - data = {"name": "new-category", "rules": [rule.pk for rule in other_rules]} - - response = self.client.post(self.url, data) - - self.assertContains(response, "not one of the available choices") - self.assertEquals(Category.objects.count(), 0) From 12693dac106facb12ff9393c10b1321b43d1c259 Mon Sep 17 00:00:00 2001 From: sonny Date: Thu, 2 Jan 2020 20:50:09 +0100 Subject: [PATCH 037/422] Fix auto read marking with category selected --- src/newsreader/js/pages/homepage/actions/posts.js | 6 ++---- .../js/pages/homepage/reducers/categories.js | 11 ++++++++++- src/newsreader/js/pages/homepage/reducers/rules.js | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/newsreader/js/pages/homepage/actions/posts.js b/src/newsreader/js/pages/homepage/actions/posts.js index 59229ab..9cfab5d 100644 --- a/src/newsreader/js/pages/homepage/actions/posts.js +++ b/src/newsreader/js/pages/homepage/actions/posts.js @@ -27,6 +27,7 @@ export const postRead = (post, section) => ({ export const markPostRead = (post, token) => { return (dispatch, getState) => { const { rules } = getState(); + const { selected } = getState(); const url = `/api/posts/${post.id}/`; const options = { @@ -38,10 +39,7 @@ export const markPostRead = (post, token) => { body: JSON.stringify({ read: true }), }; - const section = { - ...rules.items[post.rule], - type: RULE_TYPE, - }; + const section = { ...selected.item }; return fetch(url, options) .then(response => response.json()) diff --git a/src/newsreader/js/pages/homepage/reducers/categories.js b/src/newsreader/js/pages/homepage/reducers/categories.js index 90fe063..a1f8961 100644 --- a/src/newsreader/js/pages/homepage/reducers/categories.js +++ b/src/newsreader/js/pages/homepage/reducers/categories.js @@ -46,7 +46,16 @@ export const categories = (state = { ...defaultState }, action) => { isFetching: true, }; case MARK_POST_READ: - let category = { ...state.items[action.section.category] }; + let category = {}; + + switch (action.section.type) { + case CATEGORY_TYPE: + category = { ...state.items[action.section.id] }; + break; + case RULE_TYPE: + category = { ...state.items[action.section.category] }; + break; + } return { ...state, diff --git a/src/newsreader/js/pages/homepage/reducers/rules.js b/src/newsreader/js/pages/homepage/reducers/rules.js index 69cd703..0802576 100644 --- a/src/newsreader/js/pages/homepage/reducers/rules.js +++ b/src/newsreader/js/pages/homepage/reducers/rules.js @@ -34,7 +34,7 @@ export const rules = (state = { ...defaultState }, action) => { isFetching: false, }; case MARK_POST_READ: - const rule = { ...state.items[action.section.id] }; + const rule = { ...state.items[action.post.rule] }; return { ...state, From 62e763604ed493574a6570480f964c6c08ca8df4 Mon Sep 17 00:00:00 2001 From: sonny Date: Sat, 18 Jan 2020 19:44:11 +0100 Subject: [PATCH 038/422] Install all javascript dependencies by default --- .gitlab-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index cb9600d..81a727e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -11,7 +11,7 @@ javascript build: paths: - node_modules/ before_script: - - npm install --only=dev + - npm install script: - npx gulp @@ -45,7 +45,7 @@ javascript linting: paths: - node_modules/ before_script: - - npm install --only=dev + - npm install script: - npm run lint From c3b087e004b238f1fcffd16dd1e7f45f3cbe1472 Mon Sep 17 00:00:00 2001 From: Sonny Date: Thu, 30 Jan 2020 20:29:32 +0100 Subject: [PATCH 039/422] Squashed commit of the following: commit f6174179405cbf696415b17bbfcb157b6c3415cf Author: sonny Date: Thu Jan 2 23:26:49 2020 +0100 redux tests --- .gitlab-ci.yml | 12 + jest.config.js | 188 ++ package-lock.json | 2419 +++++++++++++++++ package.json | 11 +- .../js/pages/homepage/actions/categories.js | 4 +- .../js/pages/homepage/actions/posts.js | 44 +- .../js/pages/homepage/actions/rules.js | 8 +- .../js/pages/homepage/actions/selected.js | 14 +- .../components/sidebar/CategoryItem.js | 2 +- .../homepage/components/sidebar/RuleItem.js | 2 +- .../js/pages/homepage/reducers/posts.js | 13 +- .../js/pages/homepage/reducers/selected.js | 4 +- .../tests/homepage/actions/category.test.js | 250 ++ .../js/tests/homepage/actions/post.test.js | 325 +++ .../js/tests/homepage/actions/rule.test.js | 254 ++ .../tests/homepage/actions/selected.test.js | 141 + .../tests/homepage/reducers/category.test.js | 214 ++ .../js/tests/homepage/reducers/post.test.js | 304 +++ .../js/tests/homepage/reducers/rule.test.js | 181 ++ .../tests/homepage/reducers/selected.test.js | 399 +++ 20 files changed, 4733 insertions(+), 56 deletions(-) create mode 100644 jest.config.js create mode 100644 src/newsreader/js/tests/homepage/actions/category.test.js create mode 100644 src/newsreader/js/tests/homepage/actions/post.test.js create mode 100644 src/newsreader/js/tests/homepage/actions/rule.test.js create mode 100644 src/newsreader/js/tests/homepage/actions/selected.test.js create mode 100644 src/newsreader/js/tests/homepage/reducers/category.test.js create mode 100644 src/newsreader/js/tests/homepage/reducers/post.test.js create mode 100644 src/newsreader/js/tests/homepage/reducers/rule.test.js create mode 100644 src/newsreader/js/tests/homepage/reducers/selected.test.js diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 81a727e..caf624e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -37,6 +37,18 @@ python tests: script: - python src/manage.py test newsreader +javascript tests: + image: node:12 + stage: test + cache: + key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG" + paths: + - node_modules/ + before_script: + - npm install + script: + - npm test + javascript linting: image: node:12 stage: lint diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..01afeb8 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,188 @@ +// For a detailed explanation regarding each configuration property, visit: +// https://jestjs.io/docs/en/configuration.html + +module.exports = { + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + // bail: 0, + + // Respect "browser" field in package.json when resolving modules + // browser: false, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: "/tmp/jest_rs", + + // Automatically clear mock calls and instances between every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + // collectCoverage: false, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + // collectCoverageFrom: null, + + // The directory where Jest should output its coverage files + coverageDirectory: 'coverage', + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "/node_modules/" + // ], + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: null, + + // A path to a custom dependency extractor + // dependencyExtractor: null, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: null, + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: null, + + // A set of global variables that need to be available in all test environments + // globals: {}, + + // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "json", + // "jsx", + // "ts", + // "tsx", + // "node" + // ], + + // A map from regular expressions to module names that allow to stub out resources with a single module + // moduleNameMapper: {}, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + // preset: null, + + // Run tests from one or more projects + // projects: null, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state between every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + // resolver: null, + + // Automatically restore mock state between every test + // restoreMocks: false, + + // The root directory that Jest should scan for tests and modules within + rootDir: 'src/newsreader/js/tests/', + + // A list of paths to directories that Jest should use to search for files in + // roots: [ + // "" + // ], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + // setupFiles: [], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + // setupFilesAfterEnv: [], + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + testEnvironment: 'node', + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + // testMatch: [ + // "**/__tests__/**/*.[jt]s?(x)", + // "**/?(*.)+(spec|test).[tj]s?(x)" + // ], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + // testPathIgnorePatterns: [ + // "/node_modules/" + // ], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: null, + + // This option allows use of a custom test runner + // testRunner: "jasmine2", + + // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href + // testURL: "http://localhost", + + // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" + // timers: "real", + + // A map from regular expressions to paths to transformers + // transform: null, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "/node_modules/" + // ], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: null, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +}; diff --git a/package-lock.json b/package-lock.json index a7433b6..4ee3371 100644 --- a/package-lock.json +++ b/package-lock.json @@ -945,6 +945,260 @@ "to-fast-properties": "^2.0.0" } }, + "@cnakazawa/watch": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@cnakazawa/watch/-/watch-1.0.3.tgz", + "integrity": "sha512-r5160ogAvGyHsal38Kux7YYtodEKOj89RGb28ht1jh3SJb08VwRwAKKJL0bGb04Zd/3r9FL3BFIc3bBidYffCA==", + "dev": true, + "requires": { + "exec-sh": "^0.3.2", + "minimist": "^1.2.0" + } + }, + "@jest/console": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-24.9.0.tgz", + "integrity": "sha512-Zuj6b8TnKXi3q4ymac8EQfc3ea/uhLeCGThFqXeC8H9/raaH8ARPUTdId+XyGd03Z4In0/VjD2OYFcBF09fNLQ==", + "dev": true, + "requires": { + "@jest/source-map": "^24.9.0", + "chalk": "^2.0.1", + "slash": "^2.0.0" + }, + "dependencies": { + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + } + } + }, + "@jest/core": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-24.9.0.tgz", + "integrity": "sha512-Fogg3s4wlAr1VX7q+rhV9RVnUv5tD7VuWfYy1+whMiWUrvl7U3QJSJyWcDio9Lq2prqYsZaeTv2Rz24pWGkJ2A==", + "dev": true, + "requires": { + "@jest/console": "^24.7.1", + "@jest/reporters": "^24.9.0", + "@jest/test-result": "^24.9.0", + "@jest/transform": "^24.9.0", + "@jest/types": "^24.9.0", + "ansi-escapes": "^3.0.0", + "chalk": "^2.0.1", + "exit": "^0.1.2", + "graceful-fs": "^4.1.15", + "jest-changed-files": "^24.9.0", + "jest-config": "^24.9.0", + "jest-haste-map": "^24.9.0", + "jest-message-util": "^24.9.0", + "jest-regex-util": "^24.3.0", + "jest-resolve": "^24.9.0", + "jest-resolve-dependencies": "^24.9.0", + "jest-runner": "^24.9.0", + "jest-runtime": "^24.9.0", + "jest-snapshot": "^24.9.0", + "jest-util": "^24.9.0", + "jest-validate": "^24.9.0", + "jest-watcher": "^24.9.0", + "micromatch": "^3.1.10", + "p-each-series": "^1.0.0", + "realpath-native": "^1.1.0", + "rimraf": "^2.5.4", + "slash": "^2.0.0", + "strip-ansi": "^5.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "@jest/environment": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-24.9.0.tgz", + "integrity": "sha512-5A1QluTPhvdIPFYnO3sZC3smkNeXPVELz7ikPbhUj0bQjB07EoE9qtLrem14ZUYWdVayYbsjVwIiL4WBIMV4aQ==", + "dev": true, + "requires": { + "@jest/fake-timers": "^24.9.0", + "@jest/transform": "^24.9.0", + "@jest/types": "^24.9.0", + "jest-mock": "^24.9.0" + } + }, + "@jest/fake-timers": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-24.9.0.tgz", + "integrity": "sha512-eWQcNa2YSwzXWIMC5KufBh3oWRIijrQFROsIqt6v/NS9Io/gknw1jsAC9c+ih/RQX4A3O7SeWAhQeN0goKhT9A==", + "dev": true, + "requires": { + "@jest/types": "^24.9.0", + "jest-message-util": "^24.9.0", + "jest-mock": "^24.9.0" + } + }, + "@jest/reporters": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-24.9.0.tgz", + "integrity": "sha512-mu4X0yjaHrffOsWmVLzitKmmmWSQ3GGuefgNscUSWNiUNcEOSEQk9k3pERKEQVBb0Cnn88+UESIsZEMH3o88Gw==", + "dev": true, + "requires": { + "@jest/environment": "^24.9.0", + "@jest/test-result": "^24.9.0", + "@jest/transform": "^24.9.0", + "@jest/types": "^24.9.0", + "chalk": "^2.0.1", + "exit": "^0.1.2", + "glob": "^7.1.2", + "istanbul-lib-coverage": "^2.0.2", + "istanbul-lib-instrument": "^3.0.1", + "istanbul-lib-report": "^2.0.4", + "istanbul-lib-source-maps": "^3.0.1", + "istanbul-reports": "^2.2.6", + "jest-haste-map": "^24.9.0", + "jest-resolve": "^24.9.0", + "jest-runtime": "^24.9.0", + "jest-util": "^24.9.0", + "jest-worker": "^24.6.0", + "node-notifier": "^5.4.2", + "slash": "^2.0.0", + "source-map": "^0.6.0", + "string-length": "^2.0.0" + }, + "dependencies": { + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "@jest/source-map": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-24.9.0.tgz", + "integrity": "sha512-/Xw7xGlsZb4MJzNDgB7PW5crou5JqWiBQaz6xyPd3ArOg2nfn/PunV8+olXbbEZzNl591o5rWKE9BRDaFAuIBg==", + "dev": true, + "requires": { + "callsites": "^3.0.0", + "graceful-fs": "^4.1.15", + "source-map": "^0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "@jest/test-result": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-24.9.0.tgz", + "integrity": "sha512-XEFrHbBonBJ8dGp2JmF8kP/nQI/ImPpygKHwQ/SY+es59Z3L5PI4Qb9TQQMAEeYsThG1xF0k6tmG0tIKATNiiA==", + "dev": true, + "requires": { + "@jest/console": "^24.9.0", + "@jest/types": "^24.9.0", + "@types/istanbul-lib-coverage": "^2.0.0" + } + }, + "@jest/test-sequencer": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-24.9.0.tgz", + "integrity": "sha512-6qqsU4o0kW1dvA95qfNog8v8gkRN9ph6Lz7r96IvZpHdNipP2cBcb07J1Z45mz/VIS01OHJ3pY8T5fUY38tg4A==", + "dev": true, + "requires": { + "@jest/test-result": "^24.9.0", + "jest-haste-map": "^24.9.0", + "jest-runner": "^24.9.0", + "jest-runtime": "^24.9.0" + } + }, + "@jest/transform": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-24.9.0.tgz", + "integrity": "sha512-TcQUmyNRxV94S0QpMOnZl0++6RMiqpbH/ZMccFB/amku6Uwvyb1cjYX7xkp5nGNkbX4QPH/FcB6q1HBTHynLmQ==", + "dev": true, + "requires": { + "@babel/core": "^7.1.0", + "@jest/types": "^24.9.0", + "babel-plugin-istanbul": "^5.1.0", + "chalk": "^2.0.1", + "convert-source-map": "^1.4.0", + "fast-json-stable-stringify": "^2.0.0", + "graceful-fs": "^4.1.15", + "jest-haste-map": "^24.9.0", + "jest-regex-util": "^24.9.0", + "jest-util": "^24.9.0", + "micromatch": "^3.1.10", + "pirates": "^4.0.1", + "realpath-native": "^1.1.0", + "slash": "^2.0.0", + "source-map": "^0.6.1", + "write-file-atomic": "2.4.1" + }, + "dependencies": { + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "@jest/types": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-24.9.0.tgz", + "integrity": "sha512-XKK7ze1apu5JWQ5eZjHITP66AX+QsLlbaJRBGYr8pNzwcAE2JVkwnf0yqjHTsDRcjR0mujy/NmZMXw5kl+kGBw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^1.1.1", + "@types/yargs": "^13.0.0" + } + }, "@nodelib/fs.scandir": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz", @@ -971,6 +1225,47 @@ "fastq": "^1.6.0" } }, + "@types/babel__core": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.3.tgz", + "integrity": "sha512-8fBo0UR2CcwWxeX7WIIgJ7lXjasFxoYgRnFHUj+hRvKkpiBJbxhdAPTCY6/ZKM0uxANFVzt4yObSLuTiTnazDA==", + "dev": true, + "requires": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "@types/babel__generator": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.1.tgz", + "integrity": "sha512-bBKm+2VPJcMRVwNhxKu8W+5/zT7pwNEqeokFOmbvVSqGzFneNxYcEBro9Ac7/N9tlsaPYnZLK8J1LWKkMsLAew==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@types/babel__template": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.0.2.tgz", + "integrity": "sha512-/K6zCpeW7Imzgab2bLkLEbz0+1JlFSrUMdw7KoIIu+IUdu51GWaBZpd3y1VXGVXzynvGa4DaIaxNZHiON3GXUg==", + "dev": true, + "requires": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@types/babel__traverse": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.0.8.tgz", + "integrity": "sha512-yGeB2dHEdvxjP0y4UbRtQaSkXJ9649fYCmIdRoul5kfAoGCwxuCbMhag0k3RPfnuh9kPGm8x89btcfDEXdVWGw==", + "dev": true, + "requires": { + "@babel/types": "^7.3.0" + } + }, "@types/events": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz", @@ -988,6 +1283,31 @@ "@types/node": "*" } }, + "@types/istanbul-lib-coverage": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz", + "integrity": "sha512-hRJD2ahnnpLgsj6KWMYSrmXkM3rm2Dl1qkx6IOFD5FnuNPXJIG5L0dhgKXCYTRMGzU4n0wImQ/xfmRc4POUFlg==", + "dev": true + }, + "@types/istanbul-lib-report": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-1.1.1.tgz", + "integrity": "sha512-3BUTyMzbZa2DtDI2BkERNC6jJw2Mr2Y0oGI7mRxYNBPxppbtEK1F66u3bKwU2g+wxwWI7PAoRpJnOY1grJqzHg==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "*" + } + }, + "@types/istanbul-reports": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-1.1.1.tgz", + "integrity": "sha512-UpYjBi8xefVChsCoBpKShdxTllC9pwISirfoZsUa2AAdQg/Jd2KQGtSbw+ya7GPo7x/wAPlH6JBhKhAsXUEZNA==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "*", + "@types/istanbul-lib-report": "*" + } + }, "@types/minimatch": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", @@ -1000,6 +1320,27 @@ "integrity": "sha512-B8emQA1qeKerqd1dmIsQYnXi+mmAzTB7flExjmy5X1aVAKFNNNDubkavwR13kR6JnpeLp3aLoJhwn9trWPAyFQ==", "dev": true }, + "@types/stack-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz", + "integrity": "sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==", + "dev": true + }, + "@types/yargs": { + "version": "13.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-13.0.4.tgz", + "integrity": "sha512-Ke1WmBbIkVM8bpvsNEcGgQM70XcEh/nbpxQhW7FhrsbCsXSY9BmLB1+LHtD7r9zrsOcFlLiF+a/UeJsdfw3C5A==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "@types/yargs-parser": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-13.1.0.tgz", + "integrity": "sha512-gCubfBUZ6KxzoibJ+SCUc/57Ms1jz5NjHe4+dI2krNmU5zCPAphyLJYyTOg06ueIyfj+SaCUqmzun7ImlxDcKg==", + "dev": true + }, "JSONStream": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", @@ -1010,6 +1351,12 @@ "through": ">=2.2.7 <3" } }, + "abab": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.3.tgz", + "integrity": "sha512-tsFzPpcttalNjFBCFMqsKYQcWxxen1pgJR56by//QwvJc4/OUS3kPOOttx2tSIfjsylB0pYu7f5D3K1RCxUnUg==", + "dev": true + }, "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -1022,6 +1369,30 @@ "integrity": "sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ==", "dev": true }, + "acorn-globals": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-4.3.4.tgz", + "integrity": "sha512-clfQEh21R+D0leSbUdWf3OcfqyaCSAQ8Ryq00bofSekfr9W8u1jyYZo6ir0xu9Gtcf7BjcHJpnbZH7JOCpP60A==", + "dev": true, + "requires": { + "acorn": "^6.0.1", + "acorn-walk": "^6.0.1" + }, + "dependencies": { + "acorn": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.0.tgz", + "integrity": "sha512-gac8OEcQ2Li1dxIEWGZzsp2BitJxwkwcOm0zHAJLcPJaVvm58FRnk6RkuLRpU1EujipU2ZFODv2P9DLMfnV8mw==", + "dev": true + }, + "acorn-walk": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-6.2.0.tgz", + "integrity": "sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA==", + "dev": true + } + } + }, "acorn-node": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", @@ -1076,6 +1447,12 @@ "ansi-wrap": "^0.1.0" } }, + "ansi-escapes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz", + "integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==", + "dev": true + }, "ansi-gray": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/ansi-gray/-/ansi-gray-0.1.1.tgz", @@ -1189,6 +1566,12 @@ "integrity": "sha1-p5SvDAWrF1KEbudTofIRoFugxE8=", "dev": true }, + "array-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-equal/-/array-equal-1.0.0.tgz", + "integrity": "sha1-jCpe8kcv2ep0KwTHenUJO6J1fJM=", + "dev": true + }, "array-find-index": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", @@ -1326,6 +1709,12 @@ "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=", "dev": true }, + "astral-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz", + "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", + "dev": true + }, "async-done": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/async-done/-/async-done-1.3.2.tgz", @@ -1350,6 +1739,12 @@ "integrity": "sha1-NhIfhFwFeBct5Bmpfb6x0W7DRUI=", "dev": true }, + "async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", + "dev": true + }, "async-settle": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-settle/-/async-settle-1.0.0.tgz", @@ -1383,6 +1778,29 @@ "integrity": "sha512-Uvq6hVe90D0B2WEnUqtdgY1bATGz3mw33nH9Y+dmA+w5DHvUmBgkr5rM/KCHpCsiFNRUfokW/szpPPgMK2hm4A==", "dev": true }, + "babel-jest": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-24.9.0.tgz", + "integrity": "sha512-ntuddfyiN+EhMw58PTNL1ph4C9rECiQXjI4nMMBKBaNjXvqLdkXpPRcMSr4iyBrJg/+wz9brFUD6RhOAT6r4Iw==", + "dev": true, + "requires": { + "@jest/transform": "^24.9.0", + "@jest/types": "^24.9.0", + "@types/babel__core": "^7.1.0", + "babel-plugin-istanbul": "^5.1.0", + "babel-preset-jest": "^24.9.0", + "chalk": "^2.4.2", + "slash": "^2.0.0" + }, + "dependencies": { + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + } + } + }, "babel-plugin-dynamic-import-node": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.0.tgz", @@ -1392,6 +1810,61 @@ "object.assign": "^4.1.0" } }, + "babel-plugin-istanbul": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-5.2.0.tgz", + "integrity": "sha512-5LphC0USA8t4i1zCtjbbNb6jJj/9+X6P37Qfirc/70EQ34xKlMW+a1RHGwxGI+SwWpNwZ27HqvzAobeqaXwiZw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "find-up": "^3.0.0", + "istanbul-lib-instrument": "^3.3.0", + "test-exclude": "^5.2.3" + } + }, + "babel-plugin-jest-hoist": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-24.9.0.tgz", + "integrity": "sha512-2EMA2P8Vp7lG0RAzr4HXqtYwacfMErOuv1U3wrvxHX6rD1sV6xS3WXG3r8TRQ2r6w8OhvSdWt+z41hQNwNm3Xw==", + "dev": true, + "requires": { + "@types/babel__traverse": "^7.0.6" + } + }, + "babel-preset-jest": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-24.9.0.tgz", + "integrity": "sha512-izTUuhE4TMfTRPF92fFwD2QfdXaZW08qvWTFCI51V8rW5x00UuPgc3ajRoWofXOuxjfcOM5zzSYsQS3H8KGCAg==", + "dev": true, + "requires": { + "@babel/plugin-syntax-object-rest-spread": "^7.0.0", + "babel-plugin-jest-hoist": "^24.9.0" + } + }, + "babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", + "dev": true, + "requires": { + "core-js": "^2.4.0", + "regenerator-runtime": "^0.11.0" + }, + "dependencies": { + "core-js": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz", + "integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==", + "dev": true + }, + "regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", + "dev": true + } + } + }, "babelify": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/babelify/-/babelify-10.0.0.tgz", @@ -1581,6 +2054,12 @@ "umd": "^3.0.0" } }, + "browser-process-hrtime": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-0.1.3.tgz", + "integrity": "sha512-bRFnI4NnjO6cnyLmOV/7PVoDEMJChlcfN0z4s1YMBY989/SvlfMI1lgCnkFUs53e9gQF+w7qu7XdllSTiSl8Aw==", + "dev": true + }, "browser-resolve": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/browser-resolve/-/browser-resolve-1.11.3.tgz", @@ -1736,6 +2215,15 @@ "node-releases": "^1.1.42" } }, + "bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "requires": { + "node-int64": "^0.4.0" + } + }, "buffer": { "version": "5.4.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.4.3.tgz", @@ -1793,6 +2281,12 @@ "integrity": "sha512-5r2GqsoEb4qMTTN9J+WzXfjov+hjxT+j3u5K+kIVNIwAd99DLCJE9pBIMP1qVeybV6JiijL385Oz0DcYxfbOIg==", "dev": true }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, "camelcase": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz", @@ -1823,6 +2317,15 @@ "integrity": "sha512-EDnZyOJ6eYh6lHmCvCdHAFbfV4KJ9lSdfv4h/ppEhrU/Yudkl7jujwMZ1we6RX7DXqBfT04pVMQ4J+1wcTlsKA==", "dev": true }, + "capture-exit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/capture-exit/-/capture-exit-2.0.0.tgz", + "integrity": "sha512-PiT/hQmTonHhl/HFGN+Lx3JJUznrVYJ3+AQsnthneZbvW7x+f08Tk7yLJTLEOUvBTbduLeeBkxEaYXUOUrRq6g==", + "dev": true, + "requires": { + "rsvp": "^4.8.4" + } + }, "caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", @@ -1868,6 +2371,12 @@ } } }, + "ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true + }, "cipher-base": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", @@ -1947,6 +2456,12 @@ "readable-stream": "^2.3.5" } }, + "co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", + "dev": true + }, "code-point-at": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", @@ -2024,6 +2539,13 @@ "delayed-stream": "~1.0.0" } }, + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "optional": true + }, "commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", @@ -2114,6 +2636,12 @@ "is-plain-object": "^2.0.1" } }, + "core-js": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.1.tgz", + "integrity": "sha512-186WjSik2iTGfDjfdCZAxv2ormxtKgemjC3SI6PL31qOA0j5LhTDVjHChccoc7brwLvpvLPiMyRlcO88C4l1QQ==", + "dev": true + }, "core-js-compat": { "version": "3.6.1", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.6.1.tgz", @@ -2204,6 +2732,21 @@ "randomfill": "^1.0.3" } }, + "cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "dev": true + }, + "cssstyle": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-1.4.0.tgz", + "integrity": "sha512-GBrLZYZ4X4x6/QEoBnIrqb8B/f5l4+8me2dkom/j1Gtbxy0kBv6OGzKuAsGM75bkGwGAFkt56Iwg28S3XTZgSA==", + "dev": true, + "requires": { + "cssom": "0.3.x" + } + }, "currently-unhandled": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", @@ -2238,6 +2781,30 @@ "assert-plus": "^1.0.0" } }, + "data-urls": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-1.1.0.tgz", + "integrity": "sha512-YTWYI9se1P55u58gL5GkQHW4P6VJBJ5iBT+B5a7i2Tjadhv52paJG0qHX4A0OR6/t52odI64KP2YvFpkDOi3eQ==", + "dev": true, + "requires": { + "abab": "^2.0.0", + "whatwg-mimetype": "^2.2.0", + "whatwg-url": "^7.0.0" + }, + "dependencies": { + "whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "dev": true, + "requires": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + } + } + }, "debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -2264,6 +2831,12 @@ "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-0.3.8.tgz", "integrity": "sha1-wB3mPvsO7JeYgB1Ax+Da4ltYLIQ=" }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", + "dev": true + }, "default-compare": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/default-compare/-/default-compare-1.0.0.tgz", @@ -2407,6 +2980,12 @@ "integrity": "sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc=", "dev": true }, + "detect-newline": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-2.1.0.tgz", + "integrity": "sha1-9B8cEL5LAOh7XxPaaAdZ8sW/0+I=", + "dev": true + }, "detective": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz", @@ -2418,6 +2997,12 @@ "minimist": "^1.1.1" } }, + "diff-sequences": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-24.9.0.tgz", + "integrity": "sha512-Dj6Wk3tWyTE+Fo1rW8v0Xhwk80um6yFYKbuAxc9c3EZxIHFDYwbi34Uk42u1CdnIiVorvt4RmlSDjIPyzGC2ew==", + "dev": true + }, "diffie-hellman": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", @@ -2452,6 +3037,15 @@ "integrity": "sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==", "dev": true }, + "domexception": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz", + "integrity": "sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug==", + "dev": true, + "requires": { + "webidl-conversions": "^4.0.2" + } + }, "duplexer2": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", @@ -2514,6 +3108,12 @@ "minimalistic-crypto-utils": "^1.0.0" } }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, "end-of-stream": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz", @@ -2532,6 +3132,44 @@ "is-arrayish": "^0.2.1" } }, + "es-abstract": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.0.tgz", + "integrity": "sha512-yYkE07YF+6SIBmg1MsJ9dlub5L48Ek7X0qz+c/CPCHS9EBXfESorzng4cJQjJW5/pB6vDF41u7F8vUhLVDqIug==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.1.5", + "is-regex": "^1.0.5", + "object-inspect": "^1.7.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.0", + "string.prototype.trimleft": "^2.1.1", + "string.prototype.trimright": "^2.1.1" + }, + "dependencies": { + "has-symbols": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", + "dev": true + } + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, "es5-ext": { "version": "0.10.50", "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.50.tgz", @@ -2582,6 +3220,40 @@ "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", "dev": true }, + "escodegen": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.12.0.tgz", + "integrity": "sha512-TuA+EhsanGcme5T3R0L80u4t8CpbXQjegRmf7+FPTJrtCTErXFeelblRgHQa1FofEzqYYJmJ/OqjTwREp9qgmg==", + "dev": true, + "requires": { + "esprima": "^3.1.3", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "optional": true + } + } + }, + "esprima": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-3.1.3.tgz", + "integrity": "sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM=", + "dev": true + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true + }, "esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2604,6 +3276,48 @@ "safe-buffer": "^5.1.1" } }, + "exec-sh": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.4.tgz", + "integrity": "sha512-sEFIkc61v75sWeOe72qyrqg2Qg0OuLESziUDk/O/z2qgS15y2gWVFrI6f2Qn/qw/0/NCfCEsmNA4zOjkwEZT1A==", + "dev": true + }, + "execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + }, + "dependencies": { + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + } + } + }, + "exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=", + "dev": true + }, "expand-brackets": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", @@ -2648,6 +3362,20 @@ "homedir-polyfill": "^1.0.1" } }, + "expect": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-24.9.0.tgz", + "integrity": "sha512-wvVAx8XIol3Z5m9zvZXiyZOQ+sRJqNTIm6sGjdWlaZIeupQGO3WbYI+15D/AmEwZywL6wtJkbAbJtzkOfBuR0Q==", + "dev": true, + "requires": { + "@jest/types": "^24.9.0", + "ansi-styles": "^3.2.0", + "jest-get-type": "^24.9.0", + "jest-matcher-utils": "^24.9.0", + "jest-message-util": "^24.9.0", + "jest-regex-util": "^24.9.0" + } + }, "extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -2837,6 +3565,12 @@ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, "fast-safe-stringify": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz", @@ -2852,6 +3586,30 @@ "reusify": "^1.0.0" } }, + "fb-watchman": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.1.tgz", + "integrity": "sha512-DkPJKQeY6kKwmuMretBhr7G6Vodr7bFwDYTXIkfG1gjvNpaxBTQV3PbXg6bR1c1UP4jPOX0jHUbbHANL9vRjVg==", + "dev": true, + "requires": { + "bser": "2.1.1" + } + }, + "fetch-mock": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-8.3.1.tgz", + "integrity": "sha512-7IEIUvkHO6zOHbDSzkMAvkb2mx3N5xy9BS4RjFnIe8kCUDOomoNKBDKGwhTj5E0uuieo8rg55c6cUKorJuk4rg==", + "dev": true, + "requires": { + "babel-runtime": "^6.26.0", + "core-js": "^3.0.0", + "glob-to-regexp": "^0.4.0", + "lodash.isequal": "^4.5.0", + "path-to-regexp": "^2.2.1", + "querystring": "^0.2.0", + "whatwg-url": "^6.5.0" + } + }, "fill-range": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", @@ -3613,6 +4371,27 @@ "integrity": "sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=", "dev": true }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + }, + "dependencies": { + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + } + } + }, "get-value": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", @@ -3681,6 +4460,12 @@ "unique-stream": "^2.0.2" } }, + "glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, "glob-watcher": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/glob-watcher/-/glob-watcher-5.0.3.tgz", @@ -3767,6 +4552,12 @@ "integrity": "sha512-jpSvDPV4Cq/bgtpndIWbI5hmYxhQGHPC4d4cqBPb4DLniCfhJokdXhwhaDuLBGLQdvvRum/UiX6ECVIPvDXqdg==", "dev": true }, + "growly": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", + "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", + "dev": true + }, "gulp": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/gulp/-/gulp-4.0.2.tgz", @@ -3870,6 +4661,26 @@ "glogg": "^1.0.0" } }, + "handlebars": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.5.3.tgz", + "integrity": "sha512-3yPecJoJHK/4c6aZhSvxOyG4vJKDshV36VHp0iVCDVh7o9w2vwi3NSnL2MMPj3YdduqaBcu7cGbggJQM0br9xA==", + "dev": true, + "requires": { + "neo-async": "^2.6.0", + "optimist": "^0.6.1", + "source-map": "^0.6.1", + "uglify-js": "^3.1.4" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", @@ -4008,6 +4819,15 @@ "integrity": "sha512-7T/BxH19zbcCTa8XkMlbK5lTo1WtgkFi3GvdWEyNuc4Vex7/9Dqbnpsf4JMydcfj9HCg4zUWFTL3Za6lapg5/w==", "dev": true }, + "html-encoding-sniffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-1.0.2.tgz", + "integrity": "sha512-71lZziiDnsuabfdYiUeWdCVyKuqwWi23L8YeIgV9jSSZHCtb6wB1BKWooH7L3tn4/FuZJMVWyNaIDr4RGmaSYw==", + "dev": true, + "requires": { + "whatwg-encoding": "^1.0.1" + } + }, "htmlescape": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/htmlescape/-/htmlescape-1.1.1.tgz", @@ -4031,6 +4851,15 @@ "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=", "dev": true }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, "ieee754": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", @@ -4043,6 +4872,22 @@ "integrity": "sha512-MzbUSahkTW1u7JpKKjY7LCARd1fU5W2rLdxlM4kdkayuCwZImjkpluF9CM1aLewYJguPDqewLam18Y6AU69A8A==", "dev": true }, + "import-local": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-2.0.0.tgz", + "integrity": "sha512-b6s04m3O+s3CGSbqDIyP4R6aAwAeYlVq9+WUWep6iHa8ETRf9yei1U48C5MmfJmV9AiLYYBKPMq/W+/WRpQmCQ==", + "dev": true, + "requires": { + "pkg-dir": "^3.0.0", + "resolve-cwd": "^2.0.0" + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true + }, "in-publish": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/in-publish/-/in-publish-2.0.0.tgz", @@ -4175,6 +5020,21 @@ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", "dev": true }, + "is-callable": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz", + "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==", + "dev": true + }, + "is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dev": true, + "requires": { + "ci-info": "^2.0.0" + } + }, "is-data-descriptor": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", @@ -4195,6 +5055,12 @@ } } }, + "is-date-object": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz", + "integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==", + "dev": true + }, "is-descriptor": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", @@ -4235,6 +5101,18 @@ "number-is-nan": "^1.0.0" } }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true + }, "is-glob": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", @@ -4291,6 +5169,15 @@ "isobject": "^3.0.1" } }, + "is-regex": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz", + "integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==", + "dev": true, + "requires": { + "has": "^1.0.3" + } + }, "is-relative": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", @@ -4300,6 +5187,29 @@ "is-unc-path": "^1.0.0" } }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", + "dev": true + }, + "is-symbol": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", + "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", + "dev": true, + "requires": { + "has-symbols": "^1.0.1" + }, + "dependencies": { + "has-symbols": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", + "dev": true + } + } + }, "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", @@ -4333,6 +5243,12 @@ "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", "dev": true }, + "is-wsl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", + "integrity": "sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0=", + "dev": true + }, "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -4357,6 +5273,793 @@ "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", "dev": true }, + "istanbul-lib-coverage": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz", + "integrity": "sha512-8aXznuEPCJvGnMSRft4udDRDtb1V3pkQkMMI5LI+6HuQz5oQ4J2UFn1H82raA3qJtyOLkkwVqICBQkjnGtn5mA==", + "dev": true + }, + "istanbul-lib-instrument": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-3.3.0.tgz", + "integrity": "sha512-5nnIN4vo5xQZHdXno/YDXJ0G+I3dAm4XgzfSVTPLQpj/zAV2dV6Juy0yaf10/zrJOJeHoN3fraFe+XRq2bFVZA==", + "dev": true, + "requires": { + "@babel/generator": "^7.4.0", + "@babel/parser": "^7.4.3", + "@babel/template": "^7.4.0", + "@babel/traverse": "^7.4.3", + "@babel/types": "^7.4.0", + "istanbul-lib-coverage": "^2.0.5", + "semver": "^6.0.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "istanbul-lib-report": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-2.0.8.tgz", + "integrity": "sha512-fHBeG573EIihhAblwgxrSenp0Dby6tJMFR/HvlerBsrCTD5bkUuoNtn3gVh29ZCS824cGGBPn7Sg7cNk+2xUsQ==", + "dev": true, + "requires": { + "istanbul-lib-coverage": "^2.0.5", + "make-dir": "^2.1.0", + "supports-color": "^6.1.0" + }, + "dependencies": { + "supports-color": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", + "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "istanbul-lib-source-maps": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-3.0.6.tgz", + "integrity": "sha512-R47KzMtDJH6X4/YW9XTx+jrLnZnscW4VpNN+1PViSYTejLVPWv7oov+Duf8YQSPyVRUvueQqz1TcsC6mooZTXw==", + "dev": true, + "requires": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^2.0.5", + "make-dir": "^2.1.0", + "rimraf": "^2.6.3", + "source-map": "^0.6.1" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "istanbul-reports": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-2.2.6.tgz", + "integrity": "sha512-SKi4rnMyLBKe0Jy2uUdx28h8oG7ph2PPuQPvIAh31d+Ci+lSiEu4C+h3oBPuJ9+mPKhOyW0M8gY4U5NM1WLeXA==", + "dev": true, + "requires": { + "handlebars": "^4.1.2" + } + }, + "jest": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-24.9.0.tgz", + "integrity": "sha512-YvkBL1Zm7d2B1+h5fHEOdyjCG+sGMz4f8D86/0HiqJ6MB4MnDc8FgP5vdWsGnemOQro7lnYo8UakZ3+5A0jxGw==", + "dev": true, + "requires": { + "import-local": "^2.0.0", + "jest-cli": "^24.9.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "dev": true, + "requires": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + } + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true + }, + "jest-cli": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-24.9.0.tgz", + "integrity": "sha512-+VLRKyitT3BWoMeSUIHRxV/2g8y9gw91Jh5z2UmXZzkZKpbC08CSehVxgHUwTpy+HwGcns/tqafQDJW7imYvGg==", + "dev": true, + "requires": { + "@jest/core": "^24.9.0", + "@jest/test-result": "^24.9.0", + "@jest/types": "^24.9.0", + "chalk": "^2.0.1", + "exit": "^0.1.2", + "import-local": "^2.0.0", + "is-ci": "^2.0.0", + "jest-config": "^24.9.0", + "jest-util": "^24.9.0", + "jest-validate": "^24.9.0", + "prompts": "^2.0.1", + "realpath-native": "^1.1.0", + "yargs": "^13.3.0" + } + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + }, + "which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", + "dev": true + }, + "wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + } + }, + "y18n": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", + "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", + "dev": true + }, + "yargs": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.0.tgz", + "integrity": "sha512-2eehun/8ALW8TLoIl7MVaRUrg+yCnenu8B4kBlRxj3GJGDKU1Og7sMXPNm1BYyM1DOJmTZ4YeN/Nwxv+8XJsUA==", + "dev": true, + "requires": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.1" + } + }, + "yargs-parser": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.1.tgz", + "integrity": "sha512-oVAVsHz6uFrg3XQheFII8ESO2ssAf9luWuAd6Wexsu4F3OtIW0o8IribPXYrD4WC24LWtPrJlGy87y5udK+dxQ==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + }, + "jest-changed-files": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-24.9.0.tgz", + "integrity": "sha512-6aTWpe2mHF0DhL28WjdkO8LyGjs3zItPET4bMSeXU6T3ub4FPMw+mcOcbdGXQOAfmLcxofD23/5Bl9Z4AkFwqg==", + "dev": true, + "requires": { + "@jest/types": "^24.9.0", + "execa": "^1.0.0", + "throat": "^4.0.0" + } + }, + "jest-config": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-24.9.0.tgz", + "integrity": "sha512-RATtQJtVYQrp7fvWg6f5y3pEFj9I+H8sWw4aKxnDZ96mob5i5SD6ZEGWgMLXQ4LE8UurrjbdlLWdUeo+28QpfQ==", + "dev": true, + "requires": { + "@babel/core": "^7.1.0", + "@jest/test-sequencer": "^24.9.0", + "@jest/types": "^24.9.0", + "babel-jest": "^24.9.0", + "chalk": "^2.0.1", + "glob": "^7.1.1", + "jest-environment-jsdom": "^24.9.0", + "jest-environment-node": "^24.9.0", + "jest-get-type": "^24.9.0", + "jest-jasmine2": "^24.9.0", + "jest-regex-util": "^24.3.0", + "jest-resolve": "^24.9.0", + "jest-util": "^24.9.0", + "jest-validate": "^24.9.0", + "micromatch": "^3.1.10", + "pretty-format": "^24.9.0", + "realpath-native": "^1.1.0" + } + }, + "jest-diff": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-24.9.0.tgz", + "integrity": "sha512-qMfrTs8AdJE2iqrTp0hzh7kTd2PQWrsFyj9tORoKmu32xjPjeE4NyjVRDz8ybYwqS2ik8N4hsIpiVTyFeo2lBQ==", + "dev": true, + "requires": { + "chalk": "^2.0.1", + "diff-sequences": "^24.9.0", + "jest-get-type": "^24.9.0", + "pretty-format": "^24.9.0" + } + }, + "jest-docblock": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-24.9.0.tgz", + "integrity": "sha512-F1DjdpDMJMA1cN6He0FNYNZlo3yYmOtRUnktrT9Q37njYzC5WEaDdmbynIgy0L/IvXvvgsG8OsqhLPXTpfmZAA==", + "dev": true, + "requires": { + "detect-newline": "^2.1.0" + } + }, + "jest-each": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-24.9.0.tgz", + "integrity": "sha512-ONi0R4BvW45cw8s2Lrx8YgbeXL1oCQ/wIDwmsM3CqM/nlblNCPmnC3IPQlMbRFZu3wKdQ2U8BqM6lh3LJ5Bsog==", + "dev": true, + "requires": { + "@jest/types": "^24.9.0", + "chalk": "^2.0.1", + "jest-get-type": "^24.9.0", + "jest-util": "^24.9.0", + "pretty-format": "^24.9.0" + } + }, + "jest-environment-jsdom": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-24.9.0.tgz", + "integrity": "sha512-Zv9FV9NBRzLuALXjvRijO2351DRQeLYXtpD4xNvfoVFw21IOKNhZAEUKcbiEtjTkm2GsJ3boMVgkaR7rN8qetA==", + "dev": true, + "requires": { + "@jest/environment": "^24.9.0", + "@jest/fake-timers": "^24.9.0", + "@jest/types": "^24.9.0", + "jest-mock": "^24.9.0", + "jest-util": "^24.9.0", + "jsdom": "^11.5.1" + } + }, + "jest-environment-node": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-24.9.0.tgz", + "integrity": "sha512-6d4V2f4nxzIzwendo27Tr0aFm+IXWa0XEUnaH6nU0FMaozxovt+sfRvh4J47wL1OvF83I3SSTu0XK+i4Bqe7uA==", + "dev": true, + "requires": { + "@jest/environment": "^24.9.0", + "@jest/fake-timers": "^24.9.0", + "@jest/types": "^24.9.0", + "jest-mock": "^24.9.0", + "jest-util": "^24.9.0" + } + }, + "jest-get-type": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-24.9.0.tgz", + "integrity": "sha512-lUseMzAley4LhIcpSP9Jf+fTrQ4a1yHQwLNeeVa2cEmbCGeoZAtYPOIv8JaxLD/sUpKxetKGP+gsHl8f8TSj8Q==", + "dev": true + }, + "jest-haste-map": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-24.9.0.tgz", + "integrity": "sha512-kfVFmsuWui2Sj1Rp1AJ4D9HqJwE4uwTlS/vO+eRUaMmd54BFpli2XhMQnPC2k4cHFVbB2Q2C+jtI1AGLgEnCjQ==", + "dev": true, + "requires": { + "@jest/types": "^24.9.0", + "anymatch": "^2.0.0", + "fb-watchman": "^2.0.0", + "fsevents": "^1.2.7", + "graceful-fs": "^4.1.15", + "invariant": "^2.2.4", + "jest-serializer": "^24.9.0", + "jest-util": "^24.9.0", + "jest-worker": "^24.9.0", + "micromatch": "^3.1.10", + "sane": "^4.0.3", + "walker": "^1.0.7" + } + }, + "jest-jasmine2": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-24.9.0.tgz", + "integrity": "sha512-Cq7vkAgaYKp+PsX+2/JbTarrk0DmNhsEtqBXNwUHkdlbrTBLtMJINADf2mf5FkowNsq8evbPc07/qFO0AdKTzw==", + "dev": true, + "requires": { + "@babel/traverse": "^7.1.0", + "@jest/environment": "^24.9.0", + "@jest/test-result": "^24.9.0", + "@jest/types": "^24.9.0", + "chalk": "^2.0.1", + "co": "^4.6.0", + "expect": "^24.9.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^24.9.0", + "jest-matcher-utils": "^24.9.0", + "jest-message-util": "^24.9.0", + "jest-runtime": "^24.9.0", + "jest-snapshot": "^24.9.0", + "jest-util": "^24.9.0", + "pretty-format": "^24.9.0", + "throat": "^4.0.0" + } + }, + "jest-leak-detector": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-24.9.0.tgz", + "integrity": "sha512-tYkFIDsiKTGwb2FG1w8hX9V0aUb2ot8zY/2nFg087dUageonw1zrLMP4W6zsRO59dPkTSKie+D4rhMuP9nRmrA==", + "dev": true, + "requires": { + "jest-get-type": "^24.9.0", + "pretty-format": "^24.9.0" + } + }, + "jest-matcher-utils": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-24.9.0.tgz", + "integrity": "sha512-OZz2IXsu6eaiMAwe67c1T+5tUAtQyQx27/EMEkbFAGiw52tB9em+uGbzpcgYVpA8wl0hlxKPZxrly4CXU/GjHA==", + "dev": true, + "requires": { + "chalk": "^2.0.1", + "jest-diff": "^24.9.0", + "jest-get-type": "^24.9.0", + "pretty-format": "^24.9.0" + } + }, + "jest-message-util": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-24.9.0.tgz", + "integrity": "sha512-oCj8FiZ3U0hTP4aSui87P4L4jC37BtQwUMqk+zk/b11FR19BJDeZsZAvIHutWnmtw7r85UmR3CEWZ0HWU2mAlw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@jest/test-result": "^24.9.0", + "@jest/types": "^24.9.0", + "@types/stack-utils": "^1.0.1", + "chalk": "^2.0.1", + "micromatch": "^3.1.10", + "slash": "^2.0.0", + "stack-utils": "^1.0.1" + }, + "dependencies": { + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + } + } + }, + "jest-mock": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-24.9.0.tgz", + "integrity": "sha512-3BEYN5WbSq9wd+SyLDES7AHnjH9A/ROBwmz7l2y+ol+NtSFO8DYiEBzoO1CeFc9a8DYy10EO4dDFVv/wN3zl1w==", + "dev": true, + "requires": { + "@jest/types": "^24.9.0" + } + }, + "jest-pnp-resolver": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.1.tgz", + "integrity": "sha512-pgFw2tm54fzgYvc/OHrnysABEObZCUNFnhjoRjaVOCN8NYc032/gVjPaHD4Aq6ApkSieWtfKAFQtmDKAmhupnQ==", + "dev": true + }, + "jest-regex-util": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-24.9.0.tgz", + "integrity": "sha512-05Cmb6CuxaA+Ys6fjr3PhvV3bGQmO+2p2La4hFbU+W5uOc479f7FdLXUWXw4pYMAhhSZIuKHwSXSu6CsSBAXQA==", + "dev": true + }, + "jest-resolve": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-24.9.0.tgz", + "integrity": "sha512-TaLeLVL1l08YFZAt3zaPtjiVvyy4oSA6CRe+0AFPPVX3Q/VI0giIWWoAvoS5L96vj9Dqxj4fB5p2qrHCmTU/MQ==", + "dev": true, + "requires": { + "@jest/types": "^24.9.0", + "browser-resolve": "^1.11.3", + "chalk": "^2.0.1", + "jest-pnp-resolver": "^1.2.1", + "realpath-native": "^1.1.0" + } + }, + "jest-resolve-dependencies": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-24.9.0.tgz", + "integrity": "sha512-Fm7b6AlWnYhT0BXy4hXpactHIqER7erNgIsIozDXWl5dVm+k8XdGVe1oTg1JyaFnOxarMEbax3wyRJqGP2Pq+g==", + "dev": true, + "requires": { + "@jest/types": "^24.9.0", + "jest-regex-util": "^24.3.0", + "jest-snapshot": "^24.9.0" + } + }, + "jest-runner": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-24.9.0.tgz", + "integrity": "sha512-KksJQyI3/0mhcfspnxxEOBueGrd5E4vV7ADQLT9ESaCzz02WnbdbKWIf5Mkaucoaj7obQckYPVX6JJhgUcoWWg==", + "dev": true, + "requires": { + "@jest/console": "^24.7.1", + "@jest/environment": "^24.9.0", + "@jest/test-result": "^24.9.0", + "@jest/types": "^24.9.0", + "chalk": "^2.4.2", + "exit": "^0.1.2", + "graceful-fs": "^4.1.15", + "jest-config": "^24.9.0", + "jest-docblock": "^24.3.0", + "jest-haste-map": "^24.9.0", + "jest-jasmine2": "^24.9.0", + "jest-leak-detector": "^24.9.0", + "jest-message-util": "^24.9.0", + "jest-resolve": "^24.9.0", + "jest-runtime": "^24.9.0", + "jest-util": "^24.9.0", + "jest-worker": "^24.6.0", + "source-map-support": "^0.5.6", + "throat": "^4.0.0" + } + }, + "jest-runtime": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-24.9.0.tgz", + "integrity": "sha512-8oNqgnmF3v2J6PVRM2Jfuj8oX3syKmaynlDMMKQ4iyzbQzIG6th5ub/lM2bCMTmoTKM3ykcUYI2Pw9xwNtjMnw==", + "dev": true, + "requires": { + "@jest/console": "^24.7.1", + "@jest/environment": "^24.9.0", + "@jest/source-map": "^24.3.0", + "@jest/transform": "^24.9.0", + "@jest/types": "^24.9.0", + "@types/yargs": "^13.0.0", + "chalk": "^2.0.1", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.1.15", + "jest-config": "^24.9.0", + "jest-haste-map": "^24.9.0", + "jest-message-util": "^24.9.0", + "jest-mock": "^24.9.0", + "jest-regex-util": "^24.3.0", + "jest-resolve": "^24.9.0", + "jest-snapshot": "^24.9.0", + "jest-util": "^24.9.0", + "jest-validate": "^24.9.0", + "realpath-native": "^1.1.0", + "slash": "^2.0.0", + "strip-bom": "^3.0.0", + "yargs": "^13.3.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "dev": true, + "requires": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + } + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + }, + "which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", + "dev": true + }, + "wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + } + }, + "y18n": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", + "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", + "dev": true + }, + "yargs": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.0.tgz", + "integrity": "sha512-2eehun/8ALW8TLoIl7MVaRUrg+yCnenu8B4kBlRxj3GJGDKU1Og7sMXPNm1BYyM1DOJmTZ4YeN/Nwxv+8XJsUA==", + "dev": true, + "requires": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.1" + } + }, + "yargs-parser": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.1.tgz", + "integrity": "sha512-oVAVsHz6uFrg3XQheFII8ESO2ssAf9luWuAd6Wexsu4F3OtIW0o8IribPXYrD4WC24LWtPrJlGy87y5udK+dxQ==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + }, + "jest-serializer": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-24.9.0.tgz", + "integrity": "sha512-DxYipDr8OvfrKH3Kel6NdED3OXxjvxXZ1uIY2I9OFbGg+vUkkg7AGvi65qbhbWNPvDckXmzMPbK3u3HaDO49bQ==", + "dev": true + }, + "jest-snapshot": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-24.9.0.tgz", + "integrity": "sha512-uI/rszGSs73xCM0l+up7O7a40o90cnrk429LOiK3aeTvfC0HHmldbd81/B7Ix81KSFe1lwkbl7GnBGG4UfuDew==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0", + "@jest/types": "^24.9.0", + "chalk": "^2.0.1", + "expect": "^24.9.0", + "jest-diff": "^24.9.0", + "jest-get-type": "^24.9.0", + "jest-matcher-utils": "^24.9.0", + "jest-message-util": "^24.9.0", + "jest-resolve": "^24.9.0", + "mkdirp": "^0.5.1", + "natural-compare": "^1.4.0", + "pretty-format": "^24.9.0", + "semver": "^6.2.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "jest-util": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-24.9.0.tgz", + "integrity": "sha512-x+cZU8VRmOJxbA1K5oDBdxQmdq0OIdADarLxk0Mq+3XS4jgvhG/oKGWcIDCtPG0HgjxOYvF+ilPJQsAyXfbNOg==", + "dev": true, + "requires": { + "@jest/console": "^24.9.0", + "@jest/fake-timers": "^24.9.0", + "@jest/source-map": "^24.9.0", + "@jest/test-result": "^24.9.0", + "@jest/types": "^24.9.0", + "callsites": "^3.0.0", + "chalk": "^2.0.1", + "graceful-fs": "^4.1.15", + "is-ci": "^2.0.0", + "mkdirp": "^0.5.1", + "slash": "^2.0.0", + "source-map": "^0.6.0" + }, + "dependencies": { + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "jest-validate": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-24.9.0.tgz", + "integrity": "sha512-HPIt6C5ACwiqSiwi+OfSSHbK8sG7akG8eATl+IPKaeIjtPOeBUd/g3J7DghugzxrGjI93qS/+RPKe1H6PqvhRQ==", + "dev": true, + "requires": { + "@jest/types": "^24.9.0", + "camelcase": "^5.3.1", + "chalk": "^2.0.1", + "jest-get-type": "^24.9.0", + "leven": "^3.1.0", + "pretty-format": "^24.9.0" + }, + "dependencies": { + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + } + } + }, + "jest-watcher": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-24.9.0.tgz", + "integrity": "sha512-+/fLOfKPXXYJDYlks62/4R4GoT+GU1tYZed99JSCOsmzkkF7727RqKrjNAxtfO4YpGv11wybgRvCjR73lK2GZw==", + "dev": true, + "requires": { + "@jest/test-result": "^24.9.0", + "@jest/types": "^24.9.0", + "@types/yargs": "^13.0.0", + "ansi-escapes": "^3.0.0", + "chalk": "^2.0.1", + "jest-util": "^24.9.0", + "string-length": "^2.0.0" + } + }, + "jest-worker": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-24.9.0.tgz", + "integrity": "sha512-51PE4haMSXcHohnSMdM42anbvZANYTqMrr52tVKPqqsPJMzoP6FYYDVqahX/HrAoKEKz3uUPzSvKs9A3qR4iVw==", + "dev": true, + "requires": { + "merge-stream": "^2.0.0", + "supports-color": "^6.1.0" + }, + "dependencies": { + "supports-color": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", + "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, "js-base64": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.5.1.tgz", @@ -4385,12 +6088,60 @@ "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", "dev": true }, + "jsdom": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-11.12.0.tgz", + "integrity": "sha512-y8Px43oyiBM13Zc1z780FrfNLJCXTL40EWlty/LXUtcjykRBNgLlCjWXpfSPBl2iv+N7koQN+dvqszHZgT/Fjw==", + "dev": true, + "requires": { + "abab": "^2.0.0", + "acorn": "^5.5.3", + "acorn-globals": "^4.1.0", + "array-equal": "^1.0.0", + "cssom": ">= 0.3.2 < 0.4.0", + "cssstyle": "^1.0.0", + "data-urls": "^1.0.0", + "domexception": "^1.0.1", + "escodegen": "^1.9.1", + "html-encoding-sniffer": "^1.0.2", + "left-pad": "^1.3.0", + "nwsapi": "^2.0.7", + "parse5": "4.0.0", + "pn": "^1.1.0", + "request": "^2.87.0", + "request-promise-native": "^1.0.5", + "sax": "^1.2.4", + "symbol-tree": "^3.2.2", + "tough-cookie": "^2.3.4", + "w3c-hr-time": "^1.0.1", + "webidl-conversions": "^4.0.2", + "whatwg-encoding": "^1.0.3", + "whatwg-mimetype": "^2.1.0", + "whatwg-url": "^6.4.1", + "ws": "^5.2.0", + "xml-name-validator": "^3.0.0" + }, + "dependencies": { + "acorn": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.3.tgz", + "integrity": "sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw==", + "dev": true + } + } + }, "jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", "dev": true }, + "json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, "json-schema": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", @@ -4469,6 +6220,12 @@ "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", "dev": true }, + "kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true + }, "labeled-stream-splicer": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/labeled-stream-splicer/-/labeled-stream-splicer-2.0.2.tgz", @@ -4516,6 +6273,28 @@ "flush-write-stream": "^1.0.2" } }, + "left-pad": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==", + "dev": true + }, + "leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true + }, + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + } + }, "liftoff": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-3.1.0.tgz", @@ -4574,12 +6353,30 @@ "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", "dev": true }, + "lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=", + "dev": true + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=", + "dev": true + }, "lodash.memoize": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-3.0.4.tgz", "integrity": "sha1-LcvSwofLwKVcxCMovQxzYVDVPj8=", "dev": true }, + "lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", + "dev": true + }, "loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -4627,6 +6424,15 @@ "kind-of": "^6.0.2" } }, + "makeerror": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.11.tgz", + "integrity": "sha1-4BpckQnyr3lmDk6LlYd5AYT1qWw=", + "dev": true, + "requires": { + "tmpl": "1.0.x" + } + }, "map-cache": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", @@ -4712,6 +6518,12 @@ "trim-newlines": "^1.0.0" } }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, "merge2": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.3.0.tgz", @@ -4889,12 +6701,36 @@ "to-regex": "^3.0.1" } }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", + "dev": true + }, + "neo-async": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.1.tgz", + "integrity": "sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw==", + "dev": true + }, "next-tick": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=", "dev": true }, + "nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "node-fetch": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", + "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==", + "dev": true + }, "node-gyp": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-3.8.0.tgz", @@ -4932,12 +6768,31 @@ } } }, + "node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=", + "dev": true + }, "node-modules-regexp": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz", "integrity": "sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA=", "dev": true }, + "node-notifier": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-5.4.3.tgz", + "integrity": "sha512-M4UBGcs4jeOK9CjTsYwkvH6/MzuUmGCyTW+kCY7uO+1ZVr0+FHGdPdIf5CCLqAaxnRrWidyoQlNkMIIVwbKB8Q==", + "dev": true, + "requires": { + "growly": "^1.3.0", + "is-wsl": "^1.1.0", + "semver": "^5.5.0", + "shellwords": "^0.1.1", + "which": "^1.3.0" + } + }, "node-releases": { "version": "1.1.44", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.44.tgz", @@ -5046,6 +6901,15 @@ "once": "^1.3.2" } }, + "npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=", + "dev": true, + "requires": { + "path-key": "^2.0.0" + } + }, "npmlog": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", @@ -5064,6 +6928,12 @@ "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", "dev": true }, + "nwsapi": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz", + "integrity": "sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==", + "dev": true + }, "oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -5106,6 +6976,12 @@ } } }, + "object-inspect": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz", + "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==", + "dev": true + }, "object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -5145,6 +7021,16 @@ "isobject": "^3.0.0" } }, + "object.getownpropertydescriptors": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.0.tgz", + "integrity": "sha512-Z53Oah9A3TdLoblT7VKJaTDdXdT+lQO+cNpKVnya5JDe9uLvzu1YyY1yFDFrcxrlRgWrEFH0jJtD/IbuwjcEVg==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.0-next.1" + } + }, "object.map": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/object.map/-/object.map-1.0.1.tgz", @@ -5183,6 +7069,38 @@ "wrappy": "1" } }, + "optimist": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", + "dev": true, + "requires": { + "minimist": "~0.0.1", + "wordwrap": "~0.0.2" + }, + "dependencies": { + "minimist": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", + "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=", + "dev": true + } + } + }, + "optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "dev": true, + "requires": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + } + }, "ordered-read-streams": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ordered-read-streams/-/ordered-read-streams-1.0.1.tgz", @@ -5229,6 +7147,21 @@ "os-tmpdir": "^1.0.0" } }, + "p-each-series": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-1.0.0.tgz", + "integrity": "sha1-kw89Et0fUOdDRFeiLNbwSsatf3E=", + "dev": true, + "requires": { + "p-reduce": "^1.0.0" + } + }, + "p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", + "dev": true + }, "p-limit": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.1.tgz", @@ -5256,6 +7189,12 @@ "aggregate-error": "^3.0.0" } }, + "p-reduce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-1.0.0.tgz", + "integrity": "sha1-GMKw3ZNqRpClKfgjH1ig/bakffo=", + "dev": true + }, "p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -5323,6 +7262,12 @@ "integrity": "sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=", "dev": true }, + "parse5": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-4.0.0.tgz", + "integrity": "sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==", + "dev": true + }, "pascalcase": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", @@ -5353,6 +7298,12 @@ "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", "dev": true }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", + "dev": true + }, "path-parse": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", @@ -5380,6 +7331,12 @@ "integrity": "sha1-v8zcjfWxLcUsi0PsONGNcsBLqW0=", "dev": true }, + "path-to-regexp": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.4.0.tgz", + "integrity": "sha512-G6zHoVqC6GGTQkZwF4lkuEyMbVOjoBKAEybQUypI1WTkqinCOrq2x6U2+phkJ1XsEMTy4LjtwPI7HW+NVrRR2w==", + "dev": true + }, "path-type": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz", @@ -5475,18 +7432,50 @@ "extend-shallow": "^3.0.2" } }, + "pn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pn/-/pn-1.1.0.tgz", + "integrity": "sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==", + "dev": true + }, "posix-character-classes": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=", "dev": true }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", + "dev": true + }, "prettier": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz", "integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==", "dev": true }, + "pretty-format": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-24.9.0.tgz", + "integrity": "sha512-00ZMZUiHaJrNfk33guavqgvfJS30sLYf0f8+Srklv0AMPodGGHcoHgksZ3OThYnIvOd+8yMCn0YiEOogjlgsnA==", + "dev": true, + "requires": { + "@jest/types": "^24.9.0", + "ansi-regex": "^4.0.0", + "ansi-styles": "^3.2.0", + "react-is": "^16.8.4" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + } + } + }, "pretty-hrtime": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", @@ -5511,6 +7500,16 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "prompts": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.3.0.tgz", + "integrity": "sha512-NfbbPPg/74fT7wk2XYQ7hAIp9zJyZp5Fu19iRbORqqy1BhtrkZ0fPafBU+7bmn8ie69DpT0R6QpJIN2oisYjJg==", + "dev": true, + "requires": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.3" + } + }, "prop-types": { "version": "15.7.2", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", @@ -5729,6 +7728,15 @@ "readable-stream": "^2.0.2" } }, + "realpath-native": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/realpath-native/-/realpath-native-1.1.0.tgz", + "integrity": "sha512-wlgPA6cCIIg9gKz0fgAPjnzh4yR/LnXovwuo9hvyGvx3h8nX4+/iLZplfUWasXpqD8BdnGnP5njOFjkUwPzvjA==", + "dev": true, + "requires": { + "util.promisify": "^1.0.0" + } + }, "rechoir": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", @@ -5776,6 +7784,15 @@ "deep-diff": "^0.3.5" } }, + "redux-mock-store": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/redux-mock-store/-/redux-mock-store-1.5.4.tgz", + "integrity": "sha512-xmcA0O/tjCLXhh9Fuiq6pMrJCwFRaouA8436zcikdIpYWWCjU76CRk+i2bHx8EeiSiMGnB85/lZdU3wIJVXHTA==", + "dev": true, + "requires": { + "lodash.isplainobject": "^4.0.6" + } + }, "redux-thunk": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.3.0.tgz", @@ -5950,6 +7967,26 @@ "uuid": "^3.3.2" } }, + "request-promise-core": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.3.tgz", + "integrity": "sha512-QIs2+ArIGQVp5ZYbWD5ZLCY29D5CfWizP8eWnm8FoGD1TX61veauETVQbrV60662V0oFBkrDOuaBI8XgtuyYAQ==", + "dev": true, + "requires": { + "lodash": "^4.17.15" + } + }, + "request-promise-native": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/request-promise-native/-/request-promise-native-1.0.8.tgz", + "integrity": "sha512-dapwLGqkHtwL5AEbfenuzjTYg35Jd6KPytsC2/TLkVMz8rm+tNt72MGUWT1RP/aYawMpN6HqbNGBQaRcBtjQMQ==", + "dev": true, + "requires": { + "request-promise-core": "1.1.3", + "stealthy-require": "^1.1.1", + "tough-cookie": "^2.3.3" + } + }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -5971,6 +8008,15 @@ "path-parse": "^1.0.6" } }, + "resolve-cwd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-2.0.0.tgz", + "integrity": "sha1-AKn3OHVW4nA46uIyyqNypqWbZlo=", + "dev": true, + "requires": { + "resolve-from": "^3.0.0" + } + }, "resolve-dir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", @@ -5981,6 +8027,12 @@ "global-modules": "^1.0.0" } }, + "resolve-from": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz", + "integrity": "sha1-six699nWiBvItuZTM17rywoYh0g=", + "dev": true + }, "resolve-options": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/resolve-options/-/resolve-options-1.1.0.tgz", @@ -6027,6 +8079,12 @@ "inherits": "^2.0.1" } }, + "rsvp": { + "version": "4.8.5", + "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", + "integrity": "sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA==", + "dev": true + }, "run-parallel": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.1.9.tgz", @@ -6054,6 +8112,23 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true }, + "sane": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/sane/-/sane-4.1.0.tgz", + "integrity": "sha512-hhbzAgTIX8O7SHfp2c8/kREfEn4qO/9q8C9beyY6+tvZ87EpoZ3i1RIEvp27YBswnNbY9mWd6paKVmKbAgLfZA==", + "dev": true, + "requires": { + "@cnakazawa/watch": "^1.0.3", + "anymatch": "^2.0.0", + "capture-exit": "^2.0.0", + "exec-sh": "^0.3.2", + "execa": "^1.0.0", + "fb-watchman": "^2.0.0", + "micromatch": "^3.1.4", + "minimist": "^1.1.1", + "walker": "~1.0.5" + } + }, "sass-graph": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/sass-graph/-/sass-graph-2.2.4.tgz", @@ -6066,6 +8141,12 @@ "yargs": "^7.0.0" } }, + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", + "dev": true + }, "scheduler": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.18.0.tgz", @@ -6170,12 +8251,33 @@ "fast-safe-stringify": "^2.0.7" } }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", + "dev": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", + "dev": true + }, "shell-quote": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.2.tgz", "integrity": "sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg==", "dev": true }, + "shellwords": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", + "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", + "dev": true + }, "signal-exit": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", @@ -6188,6 +8290,12 @@ "integrity": "sha1-c0TLuLbib7J9ZrL8hvn21Zl1IcY=", "dev": true }, + "sisteransi": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.4.tgz", + "integrity": "sha512-/ekMoM4NJ59ivGSfKapeG+FWtrmWvA1p6FBZwXrqojw90vJu8lBmrTxCMuBCydKtkaUe2zt4PlxeTKpjwMbyig==", + "dev": true + }, "slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -6414,6 +8522,12 @@ "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=", "dev": true }, + "stack-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-1.0.2.tgz", + "integrity": "sha512-MTX+MeG5U994cazkjd/9KNAapsHnibjMLnfXodlkXw76JEea0UiNzrqidzo1emMwk7w5Qhc9jd4Bn9TBb1MFwA==", + "dev": true + }, "static-extend": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", @@ -6444,6 +8558,12 @@ "readable-stream": "^2.0.1" } }, + "stealthy-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", + "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=", + "dev": true + }, "stream-browserify": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz", @@ -6511,6 +8631,33 @@ "readable-stream": "^2.0.2" } }, + "string-length": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-2.0.0.tgz", + "integrity": "sha1-1A27aGo6zpYMHP/KVivyxF+DY+0=", + "dev": true, + "requires": { + "astral-regex": "^1.0.0", + "strip-ansi": "^4.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, "string-width": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", @@ -6533,6 +8680,26 @@ } } }, + "string.prototype.trimleft": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.1.tgz", + "integrity": "sha512-iu2AGd3PuP5Rp7x2kEZCrB2Nf41ehzh+goo8TV7z8/XDBbsvc6HQIlUl9RjkZ4oyrW1XM5UwlGl1oVEaDjg6Ag==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "function-bind": "^1.1.1" + } + }, + "string.prototype.trimright": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.1.tgz", + "integrity": "sha512-qFvWL3/+QIgZXVmJBfpHmxLB7xsUXz6HsUmP8+5dRaC3Q7oKUv9Vo6aMCRZC1smrtyECFsIT30PqBJ1gTjAs+g==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "function-bind": "^1.1.1" + } + }, "string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -6560,6 +8727,12 @@ "is-utf8": "^0.2.0" } }, + "strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=", + "dev": true + }, "strip-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-1.0.1.tgz", @@ -6602,6 +8775,12 @@ "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==" }, + "symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, "syntax-error": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/syntax-error/-/syntax-error-1.4.0.tgz", @@ -6622,6 +8801,96 @@ "inherits": "2" } }, + "test-exclude": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-5.2.3.tgz", + "integrity": "sha512-M+oxtseCFO3EDtAaGH7iiej3CBkzXqFMbzqYAACdzKui4eZA+pq3tZEwChvOdNfa7xxy8BfbmgJSIr43cC/+2g==", + "dev": true, + "requires": { + "glob": "^7.1.3", + "minimatch": "^3.0.4", + "read-pkg-up": "^4.0.0", + "require-main-filename": "^2.0.0" + }, + "dependencies": { + "load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha1-L19Fq5HjMhYjT9U62rZo607AmTs=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + } + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "requires": { + "pify": "^3.0.0" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + }, + "read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=", + "dev": true, + "requires": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + } + }, + "read-pkg-up": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-4.0.0.tgz", + "integrity": "sha512-6etQSH7nJGsK0RbG/2TeDzZFa8shjQ1um+SwQQ5cwKy0dhSXdOncEhb1CPpvQG4h7FyOV6EB6YlV0yJvZQNAkA==", + "dev": true, + "requires": { + "find-up": "^3.0.0", + "read-pkg": "^3.0.0" + } + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + } + } + }, + "throat": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/throat/-/throat-4.1.0.tgz", + "integrity": "sha1-iQN8vJLFarGJJua6TLsgDhVnKmo=", + "dev": true + }, "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -6663,6 +8932,12 @@ "process": "~0.11.0" } }, + "tmpl": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz", + "integrity": "sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE=", + "dev": true + }, "to-absolute-glob": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz", @@ -6740,6 +9015,23 @@ "punycode": "^1.4.1" } }, + "tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=", + "dev": true, + "requires": { + "punycode": "^2.1.0" + }, + "dependencies": { + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true + } + } + }, "trim-newlines": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz", @@ -6782,12 +9074,41 @@ "integrity": "sha512-MAM5dBMJCJNKs9E7JXo4CXRAansRfG0nlJxW7Wf6GZzSOvH31zClSaHdIMWLehe/EGMBkqeC55rrkaOr5Oo7Nw==", "dev": true }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2" + } + }, "typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", "dev": true }, + "uglify-js": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.7.3.tgz", + "integrity": "sha512-7tINm46/3puUA4hCkKYo4Xdts+JDaVC9ZPRcG8Xw9R4nhO/gZgUM3TENq8IF4Vatk8qCig4MzP/c8G4u2BkVQg==", + "dev": true, + "optional": true, + "requires": { + "commander": "~2.20.3", + "source-map": "~0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "optional": true + } + } + }, "umd": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/umd/-/umd-3.0.3.tgz", @@ -7002,6 +9323,16 @@ "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "dev": true }, + "util.promisify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.0.tgz", + "integrity": "sha512-i+6qA2MPhvoKLuxnJNpXAGhg7HphQOSUq2LKMZD0m15EiskXUkMvKdF4Uui0WYeCUGea+o2cw/ZuwehtfsrNkA==", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "object.getownpropertydescriptors": "^2.0.3" + } + }, "uuid": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz", @@ -7133,6 +9464,56 @@ "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==", "dev": true }, + "w3c-hr-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz", + "integrity": "sha1-gqwr/2PZUOqeMYmlimViX+3xkEU=", + "dev": true, + "requires": { + "browser-process-hrtime": "^0.1.2" + } + }, + "walker": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.7.tgz", + "integrity": "sha1-L3+bj9ENZ3JisYqITijRlhjgKPs=", + "dev": true, + "requires": { + "makeerror": "1.0.x" + } + }, + "webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "dev": true + }, + "whatwg-encoding": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", + "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==", + "dev": true, + "requires": { + "iconv-lite": "0.4.24" + } + }, + "whatwg-mimetype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", + "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==", + "dev": true + }, + "whatwg-url": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.5.0.tgz", + "integrity": "sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==", + "dev": true, + "requires": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, "which": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", @@ -7157,6 +9538,18 @@ "string-width": "^1.0.2 || 2" } }, + "word-wrap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "dev": true + }, + "wordwrap": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=", + "dev": true + }, "wrap-ansi": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", @@ -7173,6 +9566,32 @@ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true }, + "write-file-atomic": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.4.1.tgz", + "integrity": "sha512-TGHFeZEZMnv+gBFRfjAcxL5bPHrsGKtnb4qsFAws7/vlh+QfwAaySIw4AXP9ZskTTh5GWu3FLuJhsWVdiJPGvg==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.11", + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.2" + } + }, + "ws": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-5.2.2.tgz", + "integrity": "sha512-jaHFD6PFv6UgoIVda6qZllptQsMlDEJkTQcybzzXDYM1XO9Y8em691FGMPmM46WGyLU4z9KMgQN+qrux/nhlHA==", + "dev": true, + "requires": { + "async-limiter": "~1.0.0" + } + }, + "xml-name-validator": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", + "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", + "dev": true + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index f19d3fb..da22e0d 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,10 @@ "main": "index.js", "scripts": { "lint": "prettier \"src/newsreader/js/**/*.js\" --check", - "format": "prettier \"src/newsreader/js/**/*.js\" --write" + "format": "prettier \"src/newsreader/js/**/*.js\" --write", + "watch": "npx gulp watch", + "test": "jest", + "test:watch": "npm test -- --watch" }, "repository": { "type": "git", @@ -16,6 +19,7 @@ "dependencies": { "js-cookie": "^2.2.1", "lodash": "^4.17.15", + "object-assign": "^4.1.1", "react-redux": "^7.1.3", "redux": "^4.0.5", "redux-logger": "^3.0.6", @@ -32,18 +36,23 @@ "@babel/preset-env": "^7.7.7", "@babel/register": "^7.7.7", "@babel/runtime": "^7.7.7", + "babel-jest": "^24.9.0", "babelify": "^10.0.0", "browserify": "^16.5.0", "del": "^5.1.0", + "fetch-mock": "^8.3.1", "gulp": "^4.0.2", "gulp-babel": "^8.0.0", "gulp-cli": "^2.2.0", "gulp-concat": "^2.6.1", "gulp-sass": "^4.0.2", + "jest": "^24.9.0", + "node-fetch": "^2.6.0", "node-sass": "^4.13.0", "prettier": "^1.19.1", "react": "^16.12.0", "react-dom": "^16.12.0", + "redux-mock-store": "^1.5.4", "vinyl-buffer": "^1.0.1", "vinyl-source-stream": "^2.0.0" } diff --git a/src/newsreader/js/pages/homepage/actions/categories.js b/src/newsreader/js/pages/homepage/actions/categories.js index 85772a6..f30424f 100644 --- a/src/newsreader/js/pages/homepage/actions/categories.js +++ b/src/newsreader/js/pages/homepage/actions/categories.js @@ -66,7 +66,7 @@ export const fetchCategories = () => { }); }); - setTimeout(dispatch, 500, receiveRules(rules)); + dispatch(receiveRules(rules)); }); }; }; @@ -88,7 +88,7 @@ export const fetchCategory = category => { dispatch(receiveCategory({ ...json })); if (category.unread === 0) { - dispatch(fetchRulesByCategory(category)); + return dispatch(fetchRulesByCategory(category)); } }); }; diff --git a/src/newsreader/js/pages/homepage/actions/posts.js b/src/newsreader/js/pages/homepage/actions/posts.js index 9cfab5d..598dde1 100644 --- a/src/newsreader/js/pages/homepage/actions/posts.js +++ b/src/newsreader/js/pages/homepage/actions/posts.js @@ -1,4 +1,4 @@ -import { RULE_TYPE } from '../constants.js'; +import { RULE_TYPE, CATEGORY_TYPE } from '../constants.js'; export const SELECT_POST = 'SELECT_POST'; export const UNSELECT_POST = 'UNSELECT_POST'; @@ -9,14 +9,19 @@ export const REQUEST_POSTS = 'REQUEST_POSTS'; export const MARK_POST_READ = 'MARK_POST_READ'; -export const selectPost = post => ({ - type: SELECT_POST, - post, +export const requestPosts = () => ({ type: REQUEST_POSTS }); + +export const receivePosts = (posts, next) => ({ + type: RECEIVE_POSTS, + posts, + next, }); -export const unSelectPost = () => ({ - type: UNSELECT_POST, -}); +export const receivePost = post => ({ type: RECEIVE_POST, post }); + +export const selectPost = post => ({ type: SELECT_POST, post }); + +export const unSelectPost = () => ({ type: UNSELECT_POST }); export const postRead = (post, section) => ({ type: MARK_POST_READ, @@ -26,7 +31,6 @@ export const postRead = (post, section) => ({ export const markPostRead = (post, token) => { return (dispatch, getState) => { - const { rules } = getState(); const { selected } = getState(); const url = `/api/posts/${post.id}/`; @@ -50,19 +54,6 @@ export const markPostRead = (post, token) => { }; }; -export const receivePosts = json => ({ - type: RECEIVE_POSTS, - posts: json.items, - next: json.next, -}); - -export const receivePost = post => ({ - type: RECEIVE_POST, - post, -}); - -export const requestPosts = () => ({ type: REQUEST_POSTS }); - export const fetchPostsBySection = (section, page = false) => { return dispatch => { if (section.unread === 0) { @@ -73,12 +64,13 @@ export const fetchPostsBySection = (section, page = false) => { let url = null; - switch ('category' in section) { - case true: + switch (section.type) { + case RULE_TYPE: url = page ? page : `/api/rules/${section.id}/posts/?read=false`; break; - default: + case CATEGORY_TYPE: url = page ? page : `/api/categories/${section.id}/posts/?read=false`; + break; } return fetch(url) @@ -90,14 +82,14 @@ export const fetchPostsBySection = (section, page = false) => { posts[post.id] = post; }); - dispatch(receivePosts({ items: posts, next: json.next })); + dispatch(receivePosts(posts, json.next)); }) .catch(error => { if (error instanceof TypeError) { console.log(`Unable to parse posts from request: ${error}`); } - dispatch(receivePosts({ items: {}, next: null })); + dispatch(receivePosts({}, null)); }); }; }; diff --git a/src/newsreader/js/pages/homepage/actions/rules.js b/src/newsreader/js/pages/homepage/actions/rules.js index 50c8f95..41e2e06 100644 --- a/src/newsreader/js/pages/homepage/actions/rules.js +++ b/src/newsreader/js/pages/homepage/actions/rules.js @@ -49,28 +49,26 @@ export const fetchRule = rule => { // fetch & update category info when the rule is read if (rule.unread === 0) { - dispatch(fetchCategory({ ...category })); + return dispatch(fetchCategory({ ...category })); } }); }; }; export const fetchRulesByCategory = category => { - return (dispatch, getState) => { + return dispatch => { dispatch(requestRules()); return fetch(`/api/categories/${category.id}/rules/`) .then(response => response.json()) .then(responseData => { - dispatch(receiveRules()); - const rules = {}; responseData.forEach(rule => { rules[rule.id] = { ...rule }; }); - setTimeout(dispatch, 500, receiveRules(rules)); + dispatch(receiveRules(rules)); }); }; }; diff --git a/src/newsreader/js/pages/homepage/actions/selected.js b/src/newsreader/js/pages/homepage/actions/selected.js index 0a69266..f767d42 100644 --- a/src/newsreader/js/pages/homepage/actions/selected.js +++ b/src/newsreader/js/pages/homepage/actions/selected.js @@ -35,7 +35,7 @@ const markCategoryRead = (category, token) => { .then(response => response.json()) .then(updatedCategory => { dispatch(receiveCategory({ ...updatedCategory })); - dispatch( + return dispatch( markSectionRead({ ...category, ...updatedCategory, @@ -70,11 +70,11 @@ const markRuleRead = (rule, token) => { }; }; -export const markRead = (selected, token) => { - switch ('category' in selected) { - case true: - return markRuleRead(selected, token); - default: - return markCategoryRead(selected, token); +export const markRead = (section, token) => { + switch (section.type) { + case RULE_TYPE: + return markRuleRead(section, token); + case CATEGORY_TYPE: + return markCategoryRead(section, token); } }; diff --git a/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js b/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js index 6413406..b03ec30 100644 --- a/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js +++ b/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js @@ -19,7 +19,7 @@ class CategoryItem extends React.Component { const category = this.props.category; this.props.selectCategory(category); - this.props.fetchPostsBySection(category); + this.props.fetchPostsBySection({ ...category, type: CATEGORY_TYPE }); this.props.fetchCategory(category); } diff --git a/src/newsreader/js/pages/homepage/components/sidebar/RuleItem.js b/src/newsreader/js/pages/homepage/components/sidebar/RuleItem.js index 5f70cfd..7b8278d 100644 --- a/src/newsreader/js/pages/homepage/components/sidebar/RuleItem.js +++ b/src/newsreader/js/pages/homepage/components/sidebar/RuleItem.js @@ -12,7 +12,7 @@ class RuleItem extends React.Component { const rule = { ...this.props.rule }; this.props.selectRule(rule); - this.props.fetchPostsBySection(rule); + this.props.fetchPostsBySection({ ...rule, type: RULE_TYPE }); this.props.fetchRule(rule); } diff --git a/src/newsreader/js/pages/homepage/reducers/posts.js b/src/newsreader/js/pages/homepage/reducers/posts.js index 358b05b..4e7cb8c 100644 --- a/src/newsreader/js/pages/homepage/reducers/posts.js +++ b/src/newsreader/js/pages/homepage/reducers/posts.js @@ -18,23 +18,15 @@ export const posts = (state = { ...defaultState }, action) => { case RECEIVE_POSTS: return { ...state, - type: RECEIVE_POSTS, isFetching: false, items: { ...state.items, ...action.posts }, }; case REQUEST_POSTS: - return { - ...state, - type: REQUEST_POSTS, - isFetching: true, - }; + return { ...state, isFetching: true }; case RECEIVE_POST: - const items = { ...state.items, [action.post.id]: { ...action.post } }; - return { ...state, - items: items, - type: RECEIVE_POST, + items: { ...state.items, [action.post.id]: { ...action.post } }, }; case MARK_SECTION_READ: const updatedPosts = {}; @@ -66,7 +58,6 @@ export const posts = (state = { ...defaultState }, action) => { ...updatedPosts, }, }; - default: return state; } diff --git a/src/newsreader/js/pages/homepage/reducers/selected.js b/src/newsreader/js/pages/homepage/reducers/selected.js index 8b1f7f8..632654d 100644 --- a/src/newsreader/js/pages/homepage/reducers/selected.js +++ b/src/newsreader/js/pages/homepage/reducers/selected.js @@ -26,7 +26,7 @@ export const selected = (state = { ...defaultState }, action) => { if (state.item.clicks >= 2) { return { ...state, - item: { ...action.section, clicks: 0 }, + item: { ...action.section, clicks: 1 }, next: false, lastReached: false, }; @@ -43,7 +43,7 @@ export const selected = (state = { ...defaultState }, action) => { return { ...state, - item: { ...action.section, clicks: 0 }, + item: { ...action.section, clicks: 1 }, next: false, lastReached: false, }; diff --git a/src/newsreader/js/tests/homepage/actions/category.test.js b/src/newsreader/js/tests/homepage/actions/category.test.js new file mode 100644 index 0000000..235de76 --- /dev/null +++ b/src/newsreader/js/tests/homepage/actions/category.test.js @@ -0,0 +1,250 @@ +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import fetchMock from 'fetch-mock'; + +import * as actions from '../../../pages/homepage/actions/categories.js'; +import * as constants from '../../../pages/homepage/constants.js'; +import * as ruleActions from '../../../pages/homepage/actions/rules.js'; + +const middlewares = [thunk]; +const mockStore = configureMockStore(middlewares); + +describe('category actions', () => { + afterEach(() => { + fetchMock.restore(); + }); + + it('should create an action to select a category', () => { + const category = { id: 1, name: 'Test category', unread: 100 }; + + const expectedAction = { + section: { ...category, type: constants.CATEGORY_TYPE }, + type: actions.SELECT_CATEGORY, + }; + + expect(actions.selectCategory(category)).toEqual(expectedAction); + }); + + it('should create an action to receive a category', () => { + const category = { id: 1, name: 'Test category', unread: 100 }; + + const expectedAction = { + type: actions.RECEIVE_CATEGORY, + category, + }; + + expect(actions.receiveCategory(category)).toEqual(expectedAction); + }); + + it('should create an action to receive multiple categories', () => { + const categories = [ + { id: 1, name: 'Test category 1', unread: 200 }, + { id: 2, name: 'Test category 2', unread: 500 }, + { id: 3, name: 'Test category 3', unread: 600 }, + ]; + + const expectedAction = { + type: actions.RECEIVE_CATEGORIES, + categories, + }; + + expect(actions.receiveCategories(categories)).toEqual(expectedAction); + }); + + it('should create an action to request a category', () => { + const expectedAction = { type: actions.REQUEST_CATEGORY }; + + expect(actions.requestCategory()).toEqual(expectedAction); + }); + + it('should create an action to request multiple categories', () => { + const expectedAction = { type: actions.REQUEST_CATEGORIES }; + + expect(actions.requestCategories()).toEqual(expectedAction); + }); + + it('should create multiple actions when fetching a category', () => { + const category = { + id: 1, + name: 'Tech', + unread: 1138, + }; + + fetchMock.getOnce('/api/categories/1', { + body: { ...category, unread: 500 }, + headers: { 'content-type': 'application/json' }, + }); + + const expectedActions = [ + { type: actions.REQUEST_CATEGORY }, + { + type: actions.RECEIVE_CATEGORY, + category: { ...category, unread: 500 }, + }, + ]; + + const store = mockStore({ + categories: { items: {}, isFetching: false }, + rules: { items: {}, isFetching: false }, + posts: { items: {}, isFetching: false }, + selected: { item: {}, next: false, lastReached: false, post: {} }, + }); + + return store.dispatch(actions.fetchCategory(category)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it('should create multiple actions when fetching categories', () => { + const categories = { + 1: { id: 1, name: 'Tech', unread: 29 }, + 2: { id: 2, name: 'World news', unread: 956 }, + }; + + const rules = { + 4: { + id: 4, + name: 'BBC', + url: 'http://feeds.bbci.co.uk/news/world/rss.xml', + favicon: + 'https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png', + category: 2, + unread: 345, + }, + 5: { + id: 5, + name: 'Ars Technica', + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + category: 1, + unread: 7, + }, + }; + + fetchMock + .get('/api/categories/', { + body: Object.values({ ...categories }), + headers: { 'content-type': 'application/json' }, + }) + .get('/api/categories/1/rules/', { + body: [{ ...rules[5] }], + headers: { 'content-type': 'application/json' }, + }) + .get('/api/categories/2/rules/', { + body: [{ ...rules[4] }], + headers: { 'content-type': 'application/json' }, + }); + + const expectedActions = [ + { type: actions.REQUEST_CATEGORIES }, + { type: actions.RECEIVE_CATEGORIES, categories }, + { type: ruleActions.REQUEST_RULES }, + { type: ruleActions.RECEIVE_RULES, rules }, + ]; + + const store = mockStore({ + categories: { items: {}, isFetching: false }, + rules: { items: {}, isFetching: false }, + posts: { items: {}, isFetching: false }, + selected: { item: {}, next: false, lastReached: false, post: {} }, + }); + + return store.dispatch(actions.fetchCategories()).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it('should create multiple actions when fetching a category which is read', () => { + const category = { + id: 1, + name: 'Tech', + unread: 0, + }; + + const rules = { + 1: { + id: 1, + name: 'Ars Technica', + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + category: 1, + unread: 200, + }, + 2: { + id: 2, + name: 'Hacker News', + url: 'https://news.ycombinator.com/rss', + favicon: 'https://news.ycombinator.com/favicon.ico', + category: 1, + unread: 350, + }, + }; + + fetchMock + .get('/api/categories/1', { + body: { ...category, unread: 500 }, + headers: { 'content-type': 'application/json' }, + }) + .get('/api/categories/1/rules/', { + body: Object.values({ ...rules }), + headers: { 'content-type': 'application/json' }, + }); + + const expectedActions = [ + { type: actions.REQUEST_CATEGORY }, + { + type: actions.RECEIVE_CATEGORY, + category: { ...category, unread: 500 }, + }, + { type: ruleActions.REQUEST_RULES }, + { type: ruleActions.RECEIVE_RULES, rules: { ...rules } }, + ]; + + const store = mockStore({ + categories: { items: {}, isFetching: false }, + rules: { items: {}, isFetching: false }, + posts: { items: {}, isFetching: false }, + selected: { + item: { ...category, type: constants.CATEGORY_TYPE, clicks: 2 }, + next: false, + lastReached: false, + post: {}, + }, + }); + + return store.dispatch(actions.fetchCategory(category)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it('should create no actions for a category which is selected less than x', () => { + const category = { + id: 1, + name: 'Tech', + unread: 200, + }; + + fetchMock.getOnce('/api/categories/1', { + body: { ...category, unread: 100 }, + headers: { 'content-type': 'application/json' }, + }); + + const store = mockStore({ + categories: { items: {}, isFetching: false }, + rules: { items: {}, isFetching: false }, + posts: { items: {}, isFetching: false }, + selected: { + item: { ...category, type: constants.CATEGORY_TYPE, clicks: 1 }, + next: false, + lastReached: false, + post: {}, + }, + }); + + const expectedActions = []; + + store.dispatch(actions.fetchCategory(category)); + + expect(store.getActions()).toEqual(expectedActions); + }); +}); diff --git a/src/newsreader/js/tests/homepage/actions/post.test.js b/src/newsreader/js/tests/homepage/actions/post.test.js new file mode 100644 index 0000000..b9bdddf --- /dev/null +++ b/src/newsreader/js/tests/homepage/actions/post.test.js @@ -0,0 +1,325 @@ +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import fetchMock from 'fetch-mock'; + +import * as actions from '../../../pages/homepage/actions/posts.js'; +import * as constants from '../../../pages/homepage/constants.js'; + +const middlewares = [thunk]; +const mockStore = configureMockStore(middlewares); + +describe('rule actions', () => { + afterEach(() => { + fetchMock.restore(); + }); + + it('should create an action request posts', () => { + const expectedAction = { type: actions.REQUEST_POSTS }; + + expect(actions.requestPosts()).toEqual(expectedAction); + }); + + it('should create an action receive a post', () => { + const post = { + id: 2067, + remote_identifier: 'https://arstechnica.com/?p=1648607', + title: + 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', + body: + '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', + author: 'Kyle Orland', + publication_date: '2020-01-24T19:50:12Z', + url: 'https://arstechnica.com/?p=1648607', + rule: 5, + read: false, + }; + + const expectedAction = { + type: actions.RECEIVE_POST, + post, + }; + + expect(actions.receivePost(post)).toEqual(expectedAction); + }); + + it('should create an action to select a post', () => { + const post = { + id: 2067, + remote_identifier: 'https://arstechnica.com/?p=1648607', + title: + 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', + body: + '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', + author: 'Kyle Orland', + publication_date: '2020-01-24T19:50:12Z', + url: 'https://arstechnica.com/?p=1648607', + rule: 5, + read: false, + }; + + const expectedAction = { + type: actions.SELECT_POST, + post, + }; + + expect(actions.selectPost(post)).toEqual(expectedAction); + }); + + it('should create an action to unselect a post', () => { + const expectedAction = { type: actions.UNSELECT_POST }; + + expect(actions.unSelectPost()).toEqual(expectedAction); + }); + + it('should create an action mark a post read', () => { + const post = { + id: 2067, + remote_identifier: 'https://arstechnica.com/?p=1648607', + title: + 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', + body: + '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', + author: 'Kyle Orland', + publication_date: '2020-01-24T19:50:12Z', + url: 'https://arstechnica.com/?p=1648607', + rule: 5, + read: false, + }; + + const rule = { + id: 4, + name: 'Ars Technica', + unread: 100, + category: 1, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }; + + const expectedAction = { + type: actions.MARK_POST_READ, + section: rule, + post, + }; + + expect(actions.postRead(post, rule)).toEqual(expectedAction); + }); + + it('should create multiple actions to mark post read', () => { + const post = { + id: 2067, + remote_identifier: 'https://arstechnica.com/?p=1648607', + title: + 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', + body: + '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', + author: 'Kyle Orland', + publication_date: '2020-01-24T19:50:12Z', + url: 'https://arstechnica.com/?p=1648607', + rule: 5, + read: false, + }; + + const rule = { + id: 1, + name: 'Test rule', + unread: 100, + category: 1, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }; + + fetchMock.patchOnce('/api/posts/2067/', { + body: { ...post, read: true }, + headers: { 'content-type': 'application/json' }, + }); + + const store = mockStore({ + categories: { items: {}, isFetching: false }, + rules: { items: {}, isFetching: false }, + posts: { items: {}, isFetching: false }, + selected: { + item: rule, + next: false, + lastReached: false, + post: {}, + }, + }); + + const expectedActions = [ + { + type: actions.RECEIVE_POST, + post: { ...post, read: true }, + }, + { + type: actions.MARK_POST_READ, + post: { ...post, read: true }, + section: rule, + }, + ]; + + return store.dispatch(actions.markPostRead(post, 'TOKEN')).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it('should create multiple actions to fetch posts by rule', () => { + const posts = { + 2067: { + id: 2067, + remote_identifier: 'https://arstechnica.com/?p=1648607', + title: + 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', + body: + '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', + author: 'Kyle Orland', + publication_date: '2020-01-24T19:50:12Z', + url: 'https://arstechnica.com/?p=1648607', + rule: 4, + read: false, + }, + 2141: { + id: 2141, + remote_identifier: 'https://arstechnica.com/?p=1648757', + title: 'The most complete brain map ever is here: A fly’s “connectome”', + body: + 'It took 12 years and at least $40 million to chart a region about 250µm across.', + author: 'WIRED', + publication_date: '2020-01-25T11:06:46Z', + url: 'https://arstechnica.com/?p=1648757', + rule: 4, + read: false, + }, + }; + + const rule = { + id: 4, + name: 'Ars Technica', + unread: 100, + category: 1, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + type: constants.RULE_TYPE, + }; + + fetchMock.getOnce('/api/rules/4/posts/?read=false', { + body: { + count: 2, + next: 'https://durp.com/api/rules/4/posts/?page=2&read=false', + previous: null, + results: Object.values({ ...posts }), + }, + headers: { 'content-type': 'application/json' }, + }); + + const store = mockStore({ + categories: { items: {}, isFetching: false }, + rules: { items: {}, isFetching: false }, + posts: { items: {}, isFetching: false }, + selected: { item: {}, next: false, lastReached: false, post: {} }, + }); + + const expectedActions = [ + { type: actions.REQUEST_POSTS }, + { + type: actions.RECEIVE_POSTS, + next: 'https://durp.com/api/rules/4/posts/?page=2&read=false', + posts, + }, + ]; + + return store.dispatch(actions.fetchPostsBySection(rule)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it('should create multiple actions to fetch posts by category', () => { + const posts = { + 2067: { + id: 2067, + remote_identifier: 'https://arstechnica.com/?p=1648607', + title: + 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', + body: + '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', + author: 'Kyle Orland', + publication_date: '2020-01-24T19:50:12Z', + url: 'https://arstechnica.com/?p=1648607', + rule: 4, + read: false, + }, + 2141: { + id: 2141, + remote_identifier: 'https://arstechnica.com/?p=1648757', + title: 'The most complete brain map ever is here: A fly’s “connectome”', + body: + 'It took 12 years and at least $40 million to chart a region about 250µm across.', + author: 'WIRED', + publication_date: '2020-01-25T11:06:46Z', + url: 'https://arstechnica.com/?p=1648757', + rule: 4, + read: false, + }, + }; + + const category = { + id: 1, + name: 'Tech', + unread: 2, + type: constants.CATEGORY_TYPE, + }; + + fetchMock.getOnce('/api/categories/1/posts/?read=false', { + body: { + count: 2, + next: 'https://durp.com/api/categories/4/posts/?page=2&read=false', + previous: null, + results: Object.values({ ...posts }), + }, + headers: { 'content-type': 'application/json' }, + }); + + const store = mockStore({ + categories: { items: {}, isFetching: false }, + rules: { items: {}, isFetching: false }, + posts: { items: {}, isFetching: false }, + selected: { item: {}, next: false, lastReached: false, post: {} }, + }); + + const expectedActions = [ + { type: actions.REQUEST_POSTS }, + { + type: actions.RECEIVE_POSTS, + next: 'https://durp.com/api/categories/4/posts/?page=2&read=false', + posts, + }, + ]; + + return store.dispatch(actions.fetchPostsBySection(category)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it('should create no actions when fetching posts and section is read', () => { + const rule = { + id: 4, + name: 'Ars Technica', + unread: 0, + category: 1, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }; + + const store = mockStore({ + categories: { items: {}, isFetching: false }, + rules: { items: {}, isFetching: false }, + posts: { items: {}, isFetching: false }, + selected: { item: {}, next: false, lastReached: false, post: {} }, + }); + + const expectedActions = []; + + store.dispatch(actions.fetchPostsBySection(rule)); + + expect(store.getActions()).toEqual(expectedActions); + }); +}); diff --git a/src/newsreader/js/tests/homepage/actions/rule.test.js b/src/newsreader/js/tests/homepage/actions/rule.test.js new file mode 100644 index 0000000..509938d --- /dev/null +++ b/src/newsreader/js/tests/homepage/actions/rule.test.js @@ -0,0 +1,254 @@ +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import fetchMock from 'fetch-mock'; + +import * as actions from '../../../pages/homepage/actions/rules.js'; +import * as constants from '../../../pages/homepage/constants.js'; +import * as categoryActions from '../../../pages/homepage/actions/categories.js'; + +const middlewares = [thunk]; +const mockStore = configureMockStore(middlewares); + +describe('rule actions', () => { + afterEach(() => { + fetchMock.restore(); + }); + + it('should create an action to select a rule', () => { + const rule = { + id: 1, + name: 'Test rule', + unread: 100, + category: 1, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }; + + const expectedAction = { + section: { ...rule, type: constants.RULE_TYPE }, + type: actions.SELECT_RULE, + }; + + expect(actions.selectRule(rule)).toEqual(expectedAction); + }); + + it('should create an action to request a rule', () => { + const expectedAction = { type: actions.REQUEST_RULE }; + + expect(actions.requestRule()).toEqual(expectedAction); + }); + + it('should create an action to request multiple rules', () => { + const expectedAction = { type: actions.REQUEST_RULES }; + + expect(actions.requestRules()).toEqual(expectedAction); + }); + + it('should create an action to receive a rule', () => { + const rule = { + id: 1, + name: 'Test rule', + unread: 100, + category: 1, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }; + + const expectedAction = { + type: actions.RECEIVE_RULE, + rule, + }; + + expect(actions.receiveRule(rule)).toEqual(expectedAction); + }); + + it('should create an action to receive multiple rules', () => { + const rules = { + 1: { + id: 1, + name: 'Test rule', + unread: 100, + category: 1, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }, + 2: { + id: 2, + name: 'Test rule 2', + unread: 50, + category: 1, + url: 'https://xkcd.com/atom.xml', + favicon: null, + }, + }; + + const expectedAction = { + type: actions.RECEIVE_RULES, + rules, + }; + + expect(actions.receiveRules(rules)).toEqual(expectedAction); + }); + + it('should create multiple actions to fetch a rule', () => { + const rule = { + id: 1, + name: 'Test rule', + unread: 100, + category: 1, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }; + + fetchMock.getOnce('/api/rules/1', { + body: { ...rule, unread: 500 }, + headers: { 'content-type': 'application/json' }, + }); + + const store = mockStore({ + categories: { items: {}, isFetching: false }, + rules: { items: {}, isFetching: false }, + posts: { items: {}, isFetching: false }, + selected: { item: {}, next: false, lastReached: false, post: {} }, + }); + + const expectedActions = [ + { type: actions.REQUEST_RULE }, + { type: actions.RECEIVE_RULE, rule: { ...rule, unread: 500 } }, + ]; + + return store.dispatch(actions.fetchRule(rule)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it('should not create not create actions when rule is clicked less then twice', () => { + const rule = { + id: 1, + name: 'Test rule', + unread: 100, + category: 1, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }; + + fetchMock.getOnce('/api/rules/1', { + body: { ...rule, unread: 500 }, + headers: { 'content-type': 'application/json' }, + }); + + const store = mockStore({ + categories: { items: {}, isFetching: false }, + rules: { items: {}, isFetching: false }, + posts: { items: {}, isFetching: false }, + selected: { + item: { ...rule, type: constants.RULE_TYPE, clicks: 1 }, + next: false, + lastReached: false, + post: {}, + }, + }); + + const expectedActions = []; + + store.dispatch(actions.fetchRule(rule)); + + expect(store.getActions()).toEqual(expectedActions); + }); + + it('should create multiple actions to fetch a rule wich is read', () => { + const rule = { + id: 1, + name: 'Test rule', + unread: 0, + category: 1, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }; + + const category = { + id: 1, + name: 'Test category', + unread: 500, + }; + + fetchMock + .get('/api/rules/1', { + body: { ...rule, unread: 500 }, + headers: { 'content-type': 'application/json' }, + }) + .get('/api/categories/1', { + body: { ...category, unread: 2000 }, + headers: { 'content-type': 'application/json' }, + }); + + const store = mockStore({ + categories: { items: { 1: { ...category } }, isFetching: false }, + rules: { items: {}, isFetching: false }, + posts: { items: {}, isFetching: false }, + selected: { item: {}, next: false, lastReached: false, post: {} }, + }); + + const expectedActions = [ + { type: actions.REQUEST_RULE }, + { type: actions.RECEIVE_RULE, rule: { ...rule, unread: 500 } }, + { type: categoryActions.REQUEST_CATEGORY }, + { + type: categoryActions.RECEIVE_CATEGORY, + category: { ...category, unread: 2000 }, + }, + ]; + + return store.dispatch(actions.fetchRule(rule)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it('should create multiple actions when fetching rules by category', () => { + const category = { + id: 1, + name: 'Tech', + unread: 0, + }; + + const rules = { + 1: { + id: 1, + name: 'Ars Technica', + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + category: 1, + unread: 200, + }, + 2: { + id: 2, + name: 'Hacker News', + url: 'https://news.ycombinator.com/rss', + favicon: 'https://news.ycombinator.com/favicon.ico', + category: 1, + unread: 350, + }, + }; + + fetchMock.getOnce('/api/categories/1/rules/', { + body: Object.values({ ...rules }), + headers: { 'content-type': 'application/json' }, + }); + + const expectedActions = [ + { type: actions.REQUEST_RULES }, + { type: actions.RECEIVE_RULES, rules }, + ]; + + const store = mockStore({ + categories: { items: {}, isFetching: false }, + rules: { items: {}, isFetching: false }, + posts: { items: {}, isFetching: false }, + selected: {}, + }); + + return store.dispatch(actions.fetchRulesByCategory(category)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); +}); diff --git a/src/newsreader/js/tests/homepage/actions/selected.test.js b/src/newsreader/js/tests/homepage/actions/selected.test.js new file mode 100644 index 0000000..a55d232 --- /dev/null +++ b/src/newsreader/js/tests/homepage/actions/selected.test.js @@ -0,0 +1,141 @@ +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import fetchMock from 'fetch-mock'; + +import * as actions from '../../../pages/homepage/actions/selected.js'; +import * as categoryActions from '../../../pages/homepage/actions/categories.js'; +import * as ruleActions from '../../../pages/homepage/actions/rules.js'; +import * as constants from '../../../pages/homepage/constants.js'; + +const middlewares = [thunk]; +const mockStore = configureMockStore(middlewares); + +describe('category actions', () => { + afterEach(() => { + fetchMock.restore(); + }); + + it('should create an action to mark a section read', () => { + const category = { + id: 1, + name: 'Test category', + unread: 100, + type: constants.CATEGORY_TYPE, + }; + + const expectedAction = { + section: { ...category, type: constants.CATEGORY_TYPE }, + type: actions.MARK_SECTION_READ, + }; + + expect(actions.markSectionRead(category)).toEqual(expectedAction); + }); + + it('should mark a category as read', () => { + const category = { id: 1, name: 'Test category', unread: 100 }; + const rules = { + 1: { + id: 1, + name: 'Ars Technica', + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + category: 1, + unread: 200, + }, + 2: { + id: 2, + name: 'Hacker News', + url: 'https://news.ycombinator.com/rss', + favicon: 'https://news.ycombinator.com/favicon.ico', + category: 1, + unread: 350, + }, + }; + + fetchMock.postOnce('/api/categories/1/read/', { + body: { ...category, unread: 0 }, + headers: { 'content-type': 'application/json' }, + }); + + const expectedActions = [ + { type: categoryActions.REQUEST_CATEGORY }, + { + type: categoryActions.RECEIVE_CATEGORY, + category: { ...category, unread: 0 }, + }, + { + type: actions.MARK_SECTION_READ, + section: { + ...category, + unread: 0, + rules: rules, + type: constants.CATEGORY_TYPE, + }, + }, + ]; + + const store = mockStore({ + categories: { items: {}, isFetching: false }, + rules: { items: { ...rules }, isFetching: false }, + posts: { items: {}, isFetching: false }, + selected: { + item: { ...category, type: actions.CATEGORY_TYPE }, + next: false, + lastReached: false, + post: {}, + }, + }); + + return store + .dispatch(actions.markRead({ ...category, type: constants.CATEGORY_TYPE }, 'TOKEN')) + .then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it('should mark a rule as read', () => { + const rule = { + id: 1, + name: 'Ars Technica', + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + category: 1, + unread: 200, + }; + + fetchMock.postOnce('/api/rules/1/read/', { + body: { ...rule, unread: 0 }, + headers: { 'content-type': 'application/json' }, + }); + + const expectedActions = [ + { type: ruleActions.REQUEST_RULE }, + { + type: ruleActions.RECEIVE_RULE, + rule: { ...rule, unread: 0 }, + }, + { + type: actions.MARK_SECTION_READ, + section: { ...rule, type: constants.RULE_TYPE }, + }, + ]; + + const store = mockStore({ + categories: { items: {}, isFetching: false }, + rules: { items: { [rule.id]: { ...rule } }, isFetching: false }, + posts: { items: {}, isFetching: false }, + selected: { + item: { ...rule, type: constants.RULE_TYPE }, + next: false, + lastReached: false, + post: {}, + }, + }); + + return store + .dispatch(actions.markRead({ ...rule, type: constants.RULE_TYPE }, 'TOKEN')) + .then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); +}); diff --git a/src/newsreader/js/tests/homepage/reducers/category.test.js b/src/newsreader/js/tests/homepage/reducers/category.test.js new file mode 100644 index 0000000..4211ad2 --- /dev/null +++ b/src/newsreader/js/tests/homepage/reducers/category.test.js @@ -0,0 +1,214 @@ +import { categories as reducer } from '../../../pages/homepage/reducers/categories.js'; + +import * as actions from '../../../pages/homepage/actions/categories.js'; +import * as postActions from '../../../pages/homepage/actions/posts.js'; +import * as selectedActions from '../../../pages/homepage/actions/selected.js'; +import * as constants from '../../../pages/homepage/constants.js'; + +const defaultState = { items: {}, isFetching: false }; + +describe('category reducer', () => { + it('should return default state', () => { + expect(reducer(undefined, {})).toEqual(defaultState); + }); + + it('should return state after receiving category', () => { + const receivedCategory = { id: 9, name: 'Tech', unread: 291 }; + const action = { type: actions.RECEIVE_CATEGORY, category: receivedCategory }; + + const expectedState = { + ...defaultState, + items: { [receivedCategory.id]: receivedCategory }, + }; + + expect(reducer(undefined, action)).toEqual(expectedState); + }); + + it('should return state after receiving multiple categories', () => { + const receivedCategories = { + 0: { id: 9, name: 'Tech', unread: 291 }, + 1: { id: 2, name: 'World news', unread: 444 }, + }; + const action = { type: actions.RECEIVE_CATEGORIES, categories: receivedCategories }; + + const items = {}; + + Object.values({ ...receivedCategories }).forEach(category => { + items[category.id] = category; + }); + + const expectedState = { ...defaultState, items }; + + expect(reducer(undefined, action)).toEqual(expectedState); + }); + + it('should return state after requesting a category', () => { + const action = { type: actions.REQUEST_CATEGORY }; + + const expectedState = { ...defaultState, isFetching: true }; + + expect(reducer(undefined, action)).toEqual(expectedState); + }); + + it('should return state after requesting multiple categories', () => { + const action = { type: actions.REQUEST_CATEGORIES }; + + const expectedState = { ...defaultState, isFetching: true }; + + expect(reducer(undefined, action)).toEqual(expectedState); + }); + + it('should return state after marking a post read with a category selected', () => { + const category = { + id: 9, + name: 'Tech', + unread: 291, + }; + + const post = { + id: 2091, + remote_identifier: 'https://www.bbc.co.uk/news/world-asia-china-51249208', + title: 'China coronavirus spread is accelerating, Xi Jinping warns', + body: + 'China\'s president tells a high-level meeting that the country faces a "grave situation".', + author: null, + publication_date: '2020-01-26T05:54:14Z', + url: 'https://www.bbc.co.uk/news/world-asia-china-51249208', + rule: 4, + read: false, + }; + + const action = { + type: postActions.MARK_POST_READ, + section: { ...category, type: constants.CATEGORY_TYPE }, + post, + }; + + const state = { + ...defaultState, + items: { [category.id]: { ...category } }, + }; + + const expectedState = { + ...defaultState, + items: { + [category.id]: { ...category, unread: 290 }, + }, + }; + + expect(reducer(state, action)).toEqual(expectedState); + }); + + it('should return state after marking a post read with a rule selected', () => { + const category = { + id: 9, + name: 'Tech', + unread: 433, + }; + + const rule = { + id: 1, + name: 'Test rule', + unread: 100, + category: 9, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }; + + const post = { + id: 2182, + remote_identifier: 'https://arstechnica.com/?p=1648871', + title: 'Tesla needs to fix Autopilot safety flaws, demands Senator Markey', + body: + 'It should be renamed and fitted with a real driver-monitoring system, he says.', + author: 'Jonathan M. Gitlin', + publication_date: '2020-01-25T18:34:20Z', + url: 'https://arstechnica.com/?p=1648871', + rule: 1, + read: false, + }; + + const action = { + type: postActions.MARK_POST_READ, + section: { ...rule, type: constants.RULE_TYPE }, + post, + }; + + const state = { + ...defaultState, + items: { [category.id]: { ...category } }, + }; + + const expectedState = { + ...defaultState, + items: { + [category.id]: { ...category, unread: 432 }, + }, + }; + + expect(reducer(state, action)).toEqual(expectedState); + }); + + it('should return state after marking a section read with a category', () => { + const category = { + id: 9, + name: 'Tech', + unread: 433, + }; + + const action = { + type: selectedActions.MARK_SECTION_READ, + section: { ...category, type: constants.CATEGORY_TYPE }, + }; + + const state = { + ...defaultState, + items: { [category.id]: { ...category } }, + }; + + const expectedState = { + ...defaultState, + items: { + [category.id]: { ...category, unread: 0 }, + }, + }; + + expect(reducer(state, action)).toEqual(expectedState); + }); + + it('should return state after marking a section read with a rule', () => { + const category = { + id: 9, + name: 'Tech', + unread: 433, + }; + + const rule = { + id: 1, + name: 'Test rule', + unread: 211, + category: 9, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }; + + const action = { + type: selectedActions.MARK_SECTION_READ, + section: { ...rule, type: constants.RULE_TYPE }, + }; + + const state = { + ...defaultState, + items: { [category.id]: { ...category } }, + }; + + const expectedState = { + ...defaultState, + items: { + [category.id]: { ...category, unread: 222 }, + }, + }; + + expect(reducer(state, action)).toEqual(expectedState); + }); +}); diff --git a/src/newsreader/js/tests/homepage/reducers/post.test.js b/src/newsreader/js/tests/homepage/reducers/post.test.js new file mode 100644 index 0000000..fe72ce0 --- /dev/null +++ b/src/newsreader/js/tests/homepage/reducers/post.test.js @@ -0,0 +1,304 @@ +import { posts as reducer } from '../../../pages/homepage/reducers/posts.js'; + +import * as actions from '../../../pages/homepage/actions/posts.js'; +import * as selectedActions from '../../../pages/homepage/actions/selected.js'; +import * as constants from '../../../pages/homepage/constants.js'; + +const defaultState = { items: {}, isFetching: false }; + +describe('post actions', () => { + it('should return state after requesting posts', () => { + const action = { type: actions.REQUEST_POSTS }; + + const expectedState = { ...defaultState, isFetching: true }; + + expect(reducer(undefined, action)).toEqual(expectedState); + }); + + it('should return state after receiving a post', () => { + const post = { + id: 2067, + remote_identifier: 'https://arstechnica.com/?p=1648607', + title: + 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', + body: + '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', + author: 'Kyle Orland', + publication_date: '2020-01-24T19:50:12Z', + url: 'https://arstechnica.com/?p=1648607', + rule: 4, + read: false, + }; + + const action = { + type: actions.RECEIVE_POST, + post, + }; + + const expectedState = { + ...defaultState, + isFetching: false, + items: { [post.id]: post }, + }; + + expect(reducer(undefined, action)).toEqual(expectedState); + }); + + it('should return state after receiving posts', () => { + const posts = { + 2067: { + id: 2067, + remote_identifier: 'https://arstechnica.com/?p=1648607', + title: + 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', + body: + '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', + author: 'Kyle Orland', + publication_date: '2020-01-24T19:50:12Z', + url: 'https://arstechnica.com/?p=1648607', + rule: 4, + read: false, + }, + 2141: { + id: 2141, + remote_identifier: 'https://arstechnica.com/?p=1648757', + title: 'The most complete brain map ever is here: A fly’s “connectome”', + body: + 'It took 12 years and at least $40 million to chart a region about 250µm across.', + author: 'WIRED', + publication_date: '2020-01-25T11:06:46Z', + url: 'https://arstechnica.com/?p=1648757', + rule: 4, + read: false, + }, + }; + + const action = { + type: actions.RECEIVE_POSTS, + next: 'https://durp.com/api/rules/4/posts/?page=2&read=false', + posts, + }; + + const expectedState = { + ...defaultState, + isFetching: false, + items: posts, + }; + + expect(reducer(undefined, action)).toEqual(expectedState); + }); + + it('should return state after marking a rule read', () => { + const posts = { + 2067: { + id: 2067, + remote_identifier: 'https://arstechnica.com/?p=1648607', + title: + 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', + body: + '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', + author: 'Kyle Orland', + publication_date: '2020-01-24T19:50:12Z', + url: 'https://arstechnica.com/?p=1648607', + rule: 5, + read: false, + }, + 2141: { + id: 2141, + remote_identifier: 'https://arstechnica.com/?p=1648757', + title: 'The most complete brain map ever is here: A fly’s “connectome”', + body: + 'It took 12 years and at least $40 million to chart a region about 250µm across.', + author: 'WIRED', + publication_date: '2020-01-25T11:06:46Z', + url: 'https://arstechnica.com/?p=1648757', + rule: 5, + read: false, + }, + 4637: { + id: 4637, + remote_identifier: 'https://www.bbc.co.uk/news/world-asia-china-51299195', + title: "Coronavirus: Whole world 'must take action', warns WHO", + body: + 'The World Health Organization will hold a further emergency meeting on the coronavirus on Thursday.', + author: null, + publication_date: '2020-01-29T19:08:25Z', + url: 'https://www.bbc.co.uk/news/world-asia-china-51299195', + rule: 4, + read: false, + }, + 4638: { + id: 4638, + remote_identifier: 'https://www.bbc.co.uk/news/world-europe-51294305', + title: "Coronavirus: French Asians hit back at racism with 'I'm not a virus'", + body: + 'The coronavirus outbreak in Wuhan prompts French Asians to complain of a backlash against them.', + author: null, + publication_date: '2020-01-29T18:27:56Z', + url: 'https://www.bbc.co.uk/news/world-europe-51294305', + rule: 4, + read: false, + }, + }; + + const rule = { + id: 5, + name: 'Ars Technica', + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + category: 9, + unread: 544, + }; + + const action = { + type: selectedActions.MARK_SECTION_READ, + section: { ...rule, type: constants.RULE_TYPE }, + }; + + const state = { ...defaultState, items: { ...posts } }; + + const expectedState = { + ...defaultState, + isFetching: false, + items: { + ...posts, + 2067: { ...posts[2067], read: true }, + 2141: { ...posts[2141], read: true }, + }, + }; + + expect(reducer(state, action)).toEqual(expectedState); + }); + + it('should return state after marking a category read', () => { + const posts = { + 2067: { + id: 2067, + remote_identifier: 'https://arstechnica.com/?p=1648607', + title: + 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', + body: + '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', + author: 'Kyle Orland', + publication_date: '2020-01-24T19:50:12Z', + url: 'https://arstechnica.com/?p=1648607', + rule: 5, + read: false, + }, + 2141: { + id: 2141, + remote_identifier: 'https://arstechnica.com/?p=1648757', + title: 'The most complete brain map ever is here: A fly’s “connectome”', + body: + 'It took 12 years and at least $40 million to chart a region about 250µm across.', + author: 'WIRED', + publication_date: '2020-01-25T11:06:46Z', + url: 'https://arstechnica.com/?p=1648757', + rule: 5, + read: false, + }, + 4637: { + id: 4637, + remote_identifier: 'https://www.bbc.co.uk/news/world-asia-china-51299195', + title: "Coronavirus: Whole world 'must take action', warns WHO", + body: + 'The World Health Organization will hold a further emergency meeting on the coronavirus on Thursday.', + author: null, + publication_date: '2020-01-29T19:08:25Z', + url: 'https://www.bbc.co.uk/news/world-asia-china-51299195', + rule: 4, + read: false, + }, + 4638: { + id: 4638, + remote_identifier: 'https://www.bbc.co.uk/news/world-europe-51294305', + title: "Coronavirus: French Asians hit back at racism with 'I'm not a virus'", + body: + 'The coronavirus outbreak in Wuhan prompts French Asians to complain of a backlash against them.', + author: null, + publication_date: '2020-01-29T18:27:56Z', + url: 'https://www.bbc.co.uk/news/world-europe-51294305', + rule: 4, + read: false, + }, + 4589: { + id: 4589, + remote_identifier: 'https://tweakers.net/nieuws/162878', + title: 'Analyse: Nintendo verdiende miljard dollar aan mobiele games', + body: + 'Nintendo heeft tot nu toe een miljard dollar verdiend aan mobiele games, zo heeft SensorTower becijferd. Daarbij gaat het om inkomsten uit de App Store van Apple en Play Store van Google. De game die het meeste opbracht is Fire Emblem Heroes.', + author: 'Arnoud Wokke', + publication_date: '2020-01-29T19:03:01Z', + url: + 'https://tweakers.net/nieuws/162878/analyse-nintendo-verdiende-miljard-dollar-aan-mobiele-games.html', + rule: 7, + read: false, + }, + 4594: { + id: 4594, + remote_identifier: 'https://tweakers.net/nieuws/162870', + title: 'Samsung kondigt eerste tablet met 5g aan', + body: + 'Samsung heef zijn eerste tablet met 5g aangekondigd. Het gaat om een variant op de al bestaande Galaxy Tab S6, maar dan voorzien van Qualcomm X50-modem. Er gingen al maanden geruchten over de release van de tablet.', + author: 'Arnoud Wokke', + publication_date: '2020-01-29T16:29:40Z', + url: + 'https://tweakers.net/nieuws/162870/samsung-kondigt-eerste-tablet-met-5g-aan.html', + rule: 7, + read: false, + }, + }; + + const rules = { + 4: { + id: 4, + name: 'BBC', + url: 'http://feeds.bbci.co.uk/news/world/rss.xml', + favicon: + 'https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png', + category: 8, + unread: 321, + }, + 5: { + id: 4, + name: 'BBC', + url: 'http://feeds.bbci.co.uk/news/world/rss.xml', + favicon: + 'https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png', + category: 8, + unread: 632, + }, + }; + + const category = { + id: 8, + name: 'News', + unread: 953, + }; + + const action = { + type: selectedActions.MARK_SECTION_READ, + section: { + ...category, + type: constants.CATEGORY_TYPE, + rules, + }, + }; + + const state = { ...defaultState, items: { ...posts } }; + + const expectedState = { + ...defaultState, + isFetching: false, + items: { + ...posts, + 2067: { ...posts[2067], read: true }, + 2141: { ...posts[2141], read: true }, + 4637: { ...posts[4637], read: true }, + 4638: { ...posts[4638], read: true }, + }, + }; + + expect(reducer(state, action)).toEqual(expectedState); + }); +}); diff --git a/src/newsreader/js/tests/homepage/reducers/rule.test.js b/src/newsreader/js/tests/homepage/reducers/rule.test.js new file mode 100644 index 0000000..0332a51 --- /dev/null +++ b/src/newsreader/js/tests/homepage/reducers/rule.test.js @@ -0,0 +1,181 @@ +import { rules as reducer } from '../../../pages/homepage/reducers/rules.js'; + +import * as actions from '../../../pages/homepage/actions/rules.js'; +import * as postActions from '../../../pages/homepage/actions/posts.js'; +import * as selectedActions from '../../../pages/homepage/actions/selected.js'; +import * as constants from '../../../pages/homepage/constants.js'; + +const defaultState = { items: {}, isFetching: false }; + +describe('category reducer', () => { + it('should return default state', () => { + expect(reducer(undefined, {})).toEqual(defaultState); + }); + + it('should return after requesting a rule', () => { + const action = { type: actions.REQUEST_RULE }; + const expectedState = { ...defaultState, isFetching: true }; + + expect(reducer(undefined, action)).toEqual(expectedState); + }); + + it('should return after requesting multiple rules', () => { + const action = { type: actions.REQUEST_RULES }; + const expectedState = { ...defaultState, isFetching: true }; + + expect(reducer(undefined, action)).toEqual(expectedState); + }); + + it('should return state after receiving a rule', () => { + const rule = { + id: 1, + name: 'Test rule', + unread: 100, + category: 1, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }; + + const action = { type: actions.RECEIVE_RULE, rule }; + + const expectedState = { + ...defaultState, + items: { + [rule.id]: rule, + }, + }; + + expect(reducer(undefined, action)).toEqual(expectedState); + }); + + it('should return state after receiving multiple rules', () => { + const rules = { + 1: { + id: 1, + name: 'Test rule', + unread: 100, + category: 1, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }, + 2: { + id: 2, + name: 'Another Test rule', + unread: 444, + category: 1, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }, + }; + + const action = { type: actions.RECEIVE_RULES, rules }; + + const expectedState = { ...defaultState, items: { ...rules } }; + + expect(reducer(undefined, action)).toEqual(expectedState); + }); + + it('should return state after marking a post read', () => { + const rule = { + id: 1, + name: 'Test rule', + unread: 100, + category: 9, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }; + + const post = { + id: 2182, + remote_identifier: 'https://arstechnica.com/?p=1648871', + title: 'Tesla needs to fix Autopilot safety flaws, demands Senator Markey', + body: + 'It should be renamed and fitted with a real driver-monitoring system, he says.', + author: 'Jonathan M. Gitlin', + publication_date: '2020-01-25T18:34:20Z', + url: 'https://arstechnica.com/?p=1648871', + rule: 1, + read: false, + }; + + const action = { + type: postActions.MARK_POST_READ, + section: { ...rule, type: constants.RULE_TYPE }, + post, + }; + + const state = { + ...defaultState, + items: { [rule.id]: rule }, + }; + + const expectedState = { + ...defaultState, + items: { [rule.id]: { ...rule, unread: 99 } }, + }; + + expect(reducer(state, action)).toEqual(expectedState); + }); + + it('should return state after marking a category read', () => { + const category = { + id: 9, + name: 'Tech', + unread: 433, + }; + + const rule = { + id: 1, + name: 'Test rule', + unread: 100, + category: 9, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }; + + const action = { + type: selectedActions.MARK_SECTION_READ, + section: { ...category, type: constants.CATEGORY_TYPE }, + }; + + const state = { + ...defaultState, + items: { [rule.id]: rule }, + }; + + const expectedState = { + ...defaultState, + items: { [rule.id]: { ...rule, unread: 0 } }, + }; + + expect(reducer(state, action)).toEqual(expectedState); + }); + + it('should return state after marking a rule read', () => { + const rule = { + id: 1, + name: 'Test rule', + unread: 100, + category: 9, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }; + + const action = { + type: selectedActions.MARK_SECTION_READ, + section: { ...rule, type: constants.RULE_TYPE }, + }; + + const state = { + ...defaultState, + items: { [rule.id]: rule }, + }; + + const expectedState = { + ...defaultState, + items: { [rule.id]: { ...rule, unread: 0 } }, + }; + + expect(reducer(state, action)).toEqual(expectedState); + }); +}); diff --git a/src/newsreader/js/tests/homepage/reducers/selected.test.js b/src/newsreader/js/tests/homepage/reducers/selected.test.js new file mode 100644 index 0000000..365143a --- /dev/null +++ b/src/newsreader/js/tests/homepage/reducers/selected.test.js @@ -0,0 +1,399 @@ +import { selected as reducer } from '../../../pages/homepage/reducers/selected.js'; + +import * as actions from '../../../pages/homepage/actions/selected.js'; +import * as categoryActions from '../../../pages/homepage/actions/categories.js'; +import * as postActions from '../../../pages/homepage/actions/posts.js'; +import * as ruleActions from '../../../pages/homepage/actions/rules.js'; +import * as constants from '../../../pages/homepage/constants.js'; + +const defaultState = { item: {}, next: false, lastReached: false, post: {} }; + +describe('selected reducer', () => { + it('should return state', () => { + expect(reducer(undefined, {})).toEqual(defaultState); + }); + + it('should return state after selecting a category', () => { + const category = { id: 9, name: 'Tech', unread: 291 }; + + const action = { + type: categoryActions.SELECT_CATEGORY, + section: category, + }; + + const expectedState = { + ...defaultState, + item: { ...category, clicks: 1 }, + }; + + expect(reducer(undefined, action)).toEqual(expectedState); + }); + + it('should return state after selecting a rule', () => { + const rule = { + id: 1, + name: 'Test rule', + unread: 100, + category: 9, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }; + + const action = { + type: ruleActions.SELECT_RULE, + section: rule, + }; + + const expectedState = { + ...defaultState, + item: { ...rule, clicks: 1 }, + }; + + expect(reducer(undefined, action)).toEqual(expectedState); + }); + + it('should return state after selecting a category twice', () => { + const category = { id: 9, name: 'Tech', unread: 291 }; + + const action = { + type: categoryActions.SELECT_CATEGORY, + section: category, + }; + + const state = { + ...defaultState, + item: { ...category, clicks: 1 }, + }; + + const expectedState = { + ...defaultState, + item: { ...category, clicks: 2 }, + }; + + expect(reducer(state, action)).toEqual(expectedState); + }); + + it('should return state after selecting a rule twice', () => { + const rule = { + id: 1, + name: 'Test rule', + unread: 100, + category: 9, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }; + + const action = { + type: ruleActions.SELECT_RULE, + section: rule, + }; + + const state = { + ...defaultState, + item: { ...rule, clicks: 1 }, + }; + + const expectedState = { + ...defaultState, + item: { ...rule, clicks: 2 }, + }; + + expect(reducer(state, action)).toEqual(expectedState); + }); + + it('should return state after selecting a category the third time', () => { + const category = { id: 9, name: 'Tech', unread: 291 }; + + const action = { + type: categoryActions.SELECT_CATEGORY, + section: category, + }; + + const state = { + ...defaultState, + item: { ...category, clicks: 2 }, + }; + + const expectedState = { + ...defaultState, + item: { ...category, clicks: 1 }, + }; + + expect(reducer(state, action)).toEqual(expectedState); + }); + + it('should return state after selecting a rule the third time', () => { + const rule = { + id: 1, + name: 'Test rule', + unread: 100, + category: 9, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }; + + const action = { + type: ruleActions.SELECT_RULE, + section: rule, + }; + + const state = { + ...defaultState, + item: { ...rule, clicks: 2 }, + }; + + const expectedState = { + ...defaultState, + item: { ...rule, clicks: 1 }, + }; + + expect(reducer(state, action)).toEqual(expectedState); + }); + + it('should return state after selecting different (rule) section type', () => { + const category = { id: 9, name: 'Tech', unread: 291 }; + + const rule = { + id: 1, + name: 'Test rule', + unread: 100, + category: 9, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }; + + const action = { + type: ruleActions.SELECT_RULE, + section: rule, + }; + + const state = { + ...defaultState, + item: { ...category, clicks: 1 }, + }; + + const expectedState = { + ...defaultState, + item: { ...rule, clicks: 1 }, + }; + + expect(reducer(state, action)).toEqual(expectedState); + }); + + it('should return state after selecting different (category) section type', () => { + const category = { id: 9, name: 'Tech', unread: 291 }; + + const rule = { + id: 1, + name: 'Test rule', + unread: 100, + category: 9, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }; + + const action = { + type: categoryActions.SELECT_CATEGORY, + section: category, + }; + + const state = { + ...defaultState, + item: { ...rule, clicks: 1 }, + }; + + const expectedState = { + ...defaultState, + item: { ...category, clicks: 1 }, + }; + + expect(reducer(state, action)).toEqual(expectedState); + }); + + it('should return state after receiving posts', () => { + const posts = { + 2067: { + id: 2067, + remote_identifier: 'https://arstechnica.com/?p=1648607', + title: + 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', + body: + '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', + author: 'Kyle Orland', + publication_date: '2020-01-24T19:50:12Z', + url: 'https://arstechnica.com/?p=1648607', + rule: 4, + read: false, + }, + 2141: { + id: 2141, + remote_identifier: 'https://arstechnica.com/?p=1648757', + title: 'The most complete brain map ever is here: A fly’s “connectome”', + body: + 'It took 12 years and at least $40 million to chart a region about 250µm across.', + author: 'WIRED', + publication_date: '2020-01-25T11:06:46Z', + url: 'https://arstechnica.com/?p=1648757', + rule: 4, + read: false, + }, + }; + + const action = { + type: postActions.RECEIVE_POSTS, + next: 'https://durp.com/api/rules/4/posts/?page=2&read=false', + posts, + }; + + const expectedState = { + ...defaultState, + next: 'https://durp.com/api/rules/4/posts/?page=2&read=false', + lastReached: false, + }; + + expect(reducer(undefined, action)).toEqual(expectedState); + }); + + it('should return state after receiving a post', () => { + const post = { + id: 2067, + remote_identifier: 'https://arstechnica.com/?p=1648607', + title: + 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', + body: + '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', + author: 'Kyle Orland', + publication_date: '2020-01-24T19:50:12Z', + url: 'https://arstechnica.com/?p=1648607', + rule: 4, + read: false, + }; + + const action = { + type: postActions.RECEIVE_POST, + post: { ...post, rule: 6 }, + }; + + const state = { ...defaultState, post }; + const expectedState = { ...defaultState, post: { ...post, rule: 6 } }; + + expect(reducer(state, action)).toEqual(expectedState); + }); + + it('should return state after selecting a post', () => { + const post = { + id: 2067, + remote_identifier: 'https://arstechnica.com/?p=1648607', + title: + 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', + body: + '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', + author: 'Kyle Orland', + publication_date: '2020-01-24T19:50:12Z', + url: 'https://arstechnica.com/?p=1648607', + rule: 4, + read: false, + }; + + const action = { + type: postActions.SELECT_POST, + post, + }; + + const expectedState = { ...defaultState, post }; + + expect(reducer(undefined, action)).toEqual(expectedState); + }); + + it('should return state after unselecting a post', () => { + const post = { + id: 2067, + remote_identifier: 'https://arstechnica.com/?p=1648607', + title: + 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', + body: + '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', + author: 'Kyle Orland', + publication_date: '2020-01-24T19:50:12Z', + url: 'https://arstechnica.com/?p=1648607', + rule: 4, + read: false, + }; + + const action = { + type: postActions.UNSELECT_POST, + post, + }; + + const state = { ...defaultState, post }; + const expectedState = { ...defaultState, post: {} }; + + expect(reducer(state, action)).toEqual(expectedState); + }); + + it('should return state after marking a post read', () => { + const post = { + id: 2067, + remote_identifier: 'https://arstechnica.com/?p=1648607', + title: + 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', + body: + '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', + author: 'Kyle Orland', + publication_date: '2020-01-24T19:50:12Z', + url: 'https://arstechnica.com/?p=1648607', + rule: 4, + read: false, + }; + + const rule = { + id: 4, + name: 'Ars Technica', + unread: 100, + category: 1, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }; + + const action = { + type: postActions.MARK_POST_READ, + section: rule, + post, + }; + + const state = { + ...defaultState, + item: rule, + }; + const expectedState = { + ...defaultState, + item: { ...rule, unread: 99 }, + }; + + expect(reducer(state, action)).toEqual(expectedState); + }); + + it('should return state after marking a section read', () => { + const rule = { + id: 4, + name: 'Ars Technica', + unread: 100, + category: 1, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }; + + const action = { + section: { ...rule }, + type: actions.MARK_SECTION_READ, + }; + + const state = { ...defaultState, item: { ...rule, clicks: 2 } }; + const expectedState = { + ...defaultState, + item: { ...rule, unread: 0, clicks: 0 }, + }; + + expect(reducer(state, action)).toEqual(expectedState); + }); +}); From e1e6571bb0c05973746ceeffd6fc718117483d8e Mon Sep 17 00:00:00 2001 From: Sonny Date: Fri, 31 Jan 2020 21:29:11 +0100 Subject: [PATCH 040/422] Update post serializer fields --- requirements/base.txt | 2 +- .../js/pages/homepage/components/PostModal.js | 2 +- .../homepage/components/feedlist/PostItem.js | 2 +- .../homepage/components/feedlist/RuleItem.js | 2 +- .../js/tests/homepage/actions/post.test.js | 32 ++++++------ .../tests/homepage/reducers/category.test.js | 8 +-- .../js/tests/homepage/reducers/post.test.js | 52 +++++++++---------- .../js/tests/homepage/reducers/rule.test.js | 4 +- .../tests/homepage/reducers/selected.test.js | 24 ++++----- src/newsreader/news/core/serializers.py | 7 ++- .../core/tests/endpoints/post/detail/tests.py | 4 +- 11 files changed, 71 insertions(+), 68 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index b5a6858..033f18c 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -8,7 +8,7 @@ django-celery-beat==1.5.0 djangorestframework==3.9.4 django-rest-swagger==2.2.0 django-registration-redux==2.6 -lxml==4.3.4 +lxml==4.4.2 feedparser==5.2.1 idna==2.8 pytz==2018.9 diff --git a/src/newsreader/js/pages/homepage/components/PostModal.js b/src/newsreader/js/pages/homepage/components/PostModal.js index 75650fb..b077230 100644 --- a/src/newsreader/js/pages/homepage/components/PostModal.js +++ b/src/newsreader/js/pages/homepage/components/PostModal.js @@ -28,7 +28,7 @@ class PostModal extends React.Component { render() { const post = this.props.post; - const publicationDate = formatDatetime(post.publication_date); + const publicationDate = formatDatetime(post.publicationDate); const titleClassName = post.read ? 'post__title post__title--read' : 'post__title'; return ( diff --git a/src/newsreader/js/pages/homepage/components/feedlist/PostItem.js b/src/newsreader/js/pages/homepage/components/feedlist/PostItem.js index 865af41..47d4832 100644 --- a/src/newsreader/js/pages/homepage/components/feedlist/PostItem.js +++ b/src/newsreader/js/pages/homepage/components/feedlist/PostItem.js @@ -8,7 +8,7 @@ import { formatDatetime } from '../../../../utils.js'; class PostItem extends React.Component { render() { const post = this.props.post; - const publicationDate = formatDatetime(post.publication_date); + const publicationDate = formatDatetime(post.publicationDate); const titleClassName = post.read ? 'posts-header__title posts-header__title--read' : 'posts-header__title'; diff --git a/src/newsreader/js/pages/homepage/components/feedlist/RuleItem.js b/src/newsreader/js/pages/homepage/components/feedlist/RuleItem.js index f7a8a9b..608e8a1 100644 --- a/src/newsreader/js/pages/homepage/components/feedlist/RuleItem.js +++ b/src/newsreader/js/pages/homepage/components/feedlist/RuleItem.js @@ -5,7 +5,7 @@ import PostItem from './PostItem.js'; class RuleItem extends React.Component { render() { const posts = Object.values(this.props.posts).sort((firstEl, secondEl) => { - return new Date(secondEl.publication_date) - new Date(firstEl.publication_date); + return new Date(secondEl.publicationDate) - new Date(firstEl.publicationDate); }); const postItems = posts.map(post => { diff --git a/src/newsreader/js/tests/homepage/actions/post.test.js b/src/newsreader/js/tests/homepage/actions/post.test.js index b9bdddf..31bb666 100644 --- a/src/newsreader/js/tests/homepage/actions/post.test.js +++ b/src/newsreader/js/tests/homepage/actions/post.test.js @@ -22,13 +22,13 @@ describe('rule actions', () => { it('should create an action receive a post', () => { const post = { id: 2067, - remote_identifier: 'https://arstechnica.com/?p=1648607', + remoteIdentifier: 'https://arstechnica.com/?p=1648607', title: 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', body: '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', author: 'Kyle Orland', - publication_date: '2020-01-24T19:50:12Z', + publicationDate: '2020-01-24T19:50:12Z', url: 'https://arstechnica.com/?p=1648607', rule: 5, read: false, @@ -45,13 +45,13 @@ describe('rule actions', () => { it('should create an action to select a post', () => { const post = { id: 2067, - remote_identifier: 'https://arstechnica.com/?p=1648607', + remoteIdentifier: 'https://arstechnica.com/?p=1648607', title: 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', body: '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', author: 'Kyle Orland', - publication_date: '2020-01-24T19:50:12Z', + publicationDate: '2020-01-24T19:50:12Z', url: 'https://arstechnica.com/?p=1648607', rule: 5, read: false, @@ -74,13 +74,13 @@ describe('rule actions', () => { it('should create an action mark a post read', () => { const post = { id: 2067, - remote_identifier: 'https://arstechnica.com/?p=1648607', + remoteIdentifier: 'https://arstechnica.com/?p=1648607', title: 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', body: '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', author: 'Kyle Orland', - publication_date: '2020-01-24T19:50:12Z', + publicationDate: '2020-01-24T19:50:12Z', url: 'https://arstechnica.com/?p=1648607', rule: 5, read: false, @@ -107,13 +107,13 @@ describe('rule actions', () => { it('should create multiple actions to mark post read', () => { const post = { id: 2067, - remote_identifier: 'https://arstechnica.com/?p=1648607', + remoteIdentifier: 'https://arstechnica.com/?p=1648607', title: 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', body: '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', author: 'Kyle Orland', - publication_date: '2020-01-24T19:50:12Z', + publicationDate: '2020-01-24T19:50:12Z', url: 'https://arstechnica.com/?p=1648607', rule: 5, read: false, @@ -166,25 +166,25 @@ describe('rule actions', () => { const posts = { 2067: { id: 2067, - remote_identifier: 'https://arstechnica.com/?p=1648607', + remoteIdentifier: 'https://arstechnica.com/?p=1648607', title: 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', body: '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', author: 'Kyle Orland', - publication_date: '2020-01-24T19:50:12Z', + publicationDate: '2020-01-24T19:50:12Z', url: 'https://arstechnica.com/?p=1648607', rule: 4, read: false, }, 2141: { id: 2141, - remote_identifier: 'https://arstechnica.com/?p=1648757', + remoteIdentifier: 'https://arstechnica.com/?p=1648757', title: 'The most complete brain map ever is here: A fly’s “connectome”', body: 'It took 12 years and at least $40 million to chart a region about 250µm across.', author: 'WIRED', - publication_date: '2020-01-25T11:06:46Z', + publicationDate: '2020-01-25T11:06:46Z', url: 'https://arstechnica.com/?p=1648757', rule: 4, read: false, @@ -236,25 +236,25 @@ describe('rule actions', () => { const posts = { 2067: { id: 2067, - remote_identifier: 'https://arstechnica.com/?p=1648607', + remoteIdentifier: 'https://arstechnica.com/?p=1648607', title: 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', body: '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', author: 'Kyle Orland', - publication_date: '2020-01-24T19:50:12Z', + publicationDate: '2020-01-24T19:50:12Z', url: 'https://arstechnica.com/?p=1648607', rule: 4, read: false, }, 2141: { id: 2141, - remote_identifier: 'https://arstechnica.com/?p=1648757', + remoteIdentifier: 'https://arstechnica.com/?p=1648757', title: 'The most complete brain map ever is here: A fly’s “connectome”', body: 'It took 12 years and at least $40 million to chart a region about 250µm across.', author: 'WIRED', - publication_date: '2020-01-25T11:06:46Z', + publicationDate: '2020-01-25T11:06:46Z', url: 'https://arstechnica.com/?p=1648757', rule: 4, read: false, diff --git a/src/newsreader/js/tests/homepage/reducers/category.test.js b/src/newsreader/js/tests/homepage/reducers/category.test.js index 4211ad2..2eeed65 100644 --- a/src/newsreader/js/tests/homepage/reducers/category.test.js +++ b/src/newsreader/js/tests/homepage/reducers/category.test.js @@ -67,12 +67,12 @@ describe('category reducer', () => { const post = { id: 2091, - remote_identifier: 'https://www.bbc.co.uk/news/world-asia-china-51249208', + remoteIdentifier: 'https://www.bbc.co.uk/news/world-asia-china-51249208', title: 'China coronavirus spread is accelerating, Xi Jinping warns', body: 'China\'s president tells a high-level meeting that the country faces a "grave situation".', author: null, - publication_date: '2020-01-26T05:54:14Z', + publicationDate: '2020-01-26T05:54:14Z', url: 'https://www.bbc.co.uk/news/world-asia-china-51249208', rule: 4, read: false, @@ -117,12 +117,12 @@ describe('category reducer', () => { const post = { id: 2182, - remote_identifier: 'https://arstechnica.com/?p=1648871', + remoteIdentifier: 'https://arstechnica.com/?p=1648871', title: 'Tesla needs to fix Autopilot safety flaws, demands Senator Markey', body: 'It should be renamed and fitted with a real driver-monitoring system, he says.', author: 'Jonathan M. Gitlin', - publication_date: '2020-01-25T18:34:20Z', + publicationDate: '2020-01-25T18:34:20Z', url: 'https://arstechnica.com/?p=1648871', rule: 1, read: false, diff --git a/src/newsreader/js/tests/homepage/reducers/post.test.js b/src/newsreader/js/tests/homepage/reducers/post.test.js index fe72ce0..b54c3a1 100644 --- a/src/newsreader/js/tests/homepage/reducers/post.test.js +++ b/src/newsreader/js/tests/homepage/reducers/post.test.js @@ -18,13 +18,13 @@ describe('post actions', () => { it('should return state after receiving a post', () => { const post = { id: 2067, - remote_identifier: 'https://arstechnica.com/?p=1648607', + remoteIdentifier: 'https://arstechnica.com/?p=1648607', title: 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', body: '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', author: 'Kyle Orland', - publication_date: '2020-01-24T19:50:12Z', + publicationDate: '2020-01-24T19:50:12Z', url: 'https://arstechnica.com/?p=1648607', rule: 4, read: false, @@ -48,25 +48,25 @@ describe('post actions', () => { const posts = { 2067: { id: 2067, - remote_identifier: 'https://arstechnica.com/?p=1648607', + remoteIdentifier: 'https://arstechnica.com/?p=1648607', title: 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', body: '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', author: 'Kyle Orland', - publication_date: '2020-01-24T19:50:12Z', + publicationDate: '2020-01-24T19:50:12Z', url: 'https://arstechnica.com/?p=1648607', rule: 4, read: false, }, 2141: { id: 2141, - remote_identifier: 'https://arstechnica.com/?p=1648757', + remoteIdentifier: 'https://arstechnica.com/?p=1648757', title: 'The most complete brain map ever is here: A fly’s “connectome”', body: 'It took 12 years and at least $40 million to chart a region about 250µm across.', author: 'WIRED', - publication_date: '2020-01-25T11:06:46Z', + publicationDate: '2020-01-25T11:06:46Z', url: 'https://arstechnica.com/?p=1648757', rule: 4, read: false, @@ -92,49 +92,49 @@ describe('post actions', () => { const posts = { 2067: { id: 2067, - remote_identifier: 'https://arstechnica.com/?p=1648607', + remoteIdentifier: 'https://arstechnica.com/?p=1648607', title: 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', body: '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', author: 'Kyle Orland', - publication_date: '2020-01-24T19:50:12Z', + publicationDate: '2020-01-24T19:50:12Z', url: 'https://arstechnica.com/?p=1648607', rule: 5, read: false, }, 2141: { id: 2141, - remote_identifier: 'https://arstechnica.com/?p=1648757', + remoteIdentifier: 'https://arstechnica.com/?p=1648757', title: 'The most complete brain map ever is here: A fly’s “connectome”', body: 'It took 12 years and at least $40 million to chart a region about 250µm across.', author: 'WIRED', - publication_date: '2020-01-25T11:06:46Z', + publicationDate: '2020-01-25T11:06:46Z', url: 'https://arstechnica.com/?p=1648757', rule: 5, read: false, }, 4637: { id: 4637, - remote_identifier: 'https://www.bbc.co.uk/news/world-asia-china-51299195', + remoteIdentifier: 'https://www.bbc.co.uk/news/world-asia-china-51299195', title: "Coronavirus: Whole world 'must take action', warns WHO", body: 'The World Health Organization will hold a further emergency meeting on the coronavirus on Thursday.', author: null, - publication_date: '2020-01-29T19:08:25Z', + publicationDate: '2020-01-29T19:08:25Z', url: 'https://www.bbc.co.uk/news/world-asia-china-51299195', rule: 4, read: false, }, 4638: { id: 4638, - remote_identifier: 'https://www.bbc.co.uk/news/world-europe-51294305', + remoteIdentifier: 'https://www.bbc.co.uk/news/world-europe-51294305', title: "Coronavirus: French Asians hit back at racism with 'I'm not a virus'", body: 'The coronavirus outbreak in Wuhan prompts French Asians to complain of a backlash against them.', author: null, - publication_date: '2020-01-29T18:27:56Z', + publicationDate: '2020-01-29T18:27:56Z', url: 'https://www.bbc.co.uk/news/world-europe-51294305', rule: 4, read: false, @@ -174,61 +174,61 @@ describe('post actions', () => { const posts = { 2067: { id: 2067, - remote_identifier: 'https://arstechnica.com/?p=1648607', + remoteIdentifier: 'https://arstechnica.com/?p=1648607', title: 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', body: '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', author: 'Kyle Orland', - publication_date: '2020-01-24T19:50:12Z', + publicationDate: '2020-01-24T19:50:12Z', url: 'https://arstechnica.com/?p=1648607', rule: 5, read: false, }, 2141: { id: 2141, - remote_identifier: 'https://arstechnica.com/?p=1648757', + remoteIdentifier: 'https://arstechnica.com/?p=1648757', title: 'The most complete brain map ever is here: A fly’s “connectome”', body: 'It took 12 years and at least $40 million to chart a region about 250µm across.', author: 'WIRED', - publication_date: '2020-01-25T11:06:46Z', + publicationDate: '2020-01-25T11:06:46Z', url: 'https://arstechnica.com/?p=1648757', rule: 5, read: false, }, 4637: { id: 4637, - remote_identifier: 'https://www.bbc.co.uk/news/world-asia-china-51299195', + remoteIdentifier: 'https://www.bbc.co.uk/news/world-asia-china-51299195', title: "Coronavirus: Whole world 'must take action', warns WHO", body: 'The World Health Organization will hold a further emergency meeting on the coronavirus on Thursday.', author: null, - publication_date: '2020-01-29T19:08:25Z', + publicationDate: '2020-01-29T19:08:25Z', url: 'https://www.bbc.co.uk/news/world-asia-china-51299195', rule: 4, read: false, }, 4638: { id: 4638, - remote_identifier: 'https://www.bbc.co.uk/news/world-europe-51294305', + remoteIdentifier: 'https://www.bbc.co.uk/news/world-europe-51294305', title: "Coronavirus: French Asians hit back at racism with 'I'm not a virus'", body: 'The coronavirus outbreak in Wuhan prompts French Asians to complain of a backlash against them.', author: null, - publication_date: '2020-01-29T18:27:56Z', + publicationDate: '2020-01-29T18:27:56Z', url: 'https://www.bbc.co.uk/news/world-europe-51294305', rule: 4, read: false, }, 4589: { id: 4589, - remote_identifier: 'https://tweakers.net/nieuws/162878', + remoteIdentifier: 'https://tweakers.net/nieuws/162878', title: 'Analyse: Nintendo verdiende miljard dollar aan mobiele games', body: 'Nintendo heeft tot nu toe een miljard dollar verdiend aan mobiele games, zo heeft SensorTower becijferd. Daarbij gaat het om inkomsten uit de App Store van Apple en Play Store van Google. De game die het meeste opbracht is Fire Emblem Heroes.', author: 'Arnoud Wokke', - publication_date: '2020-01-29T19:03:01Z', + publicationDate: '2020-01-29T19:03:01Z', url: 'https://tweakers.net/nieuws/162878/analyse-nintendo-verdiende-miljard-dollar-aan-mobiele-games.html', rule: 7, @@ -236,12 +236,12 @@ describe('post actions', () => { }, 4594: { id: 4594, - remote_identifier: 'https://tweakers.net/nieuws/162870', + remoteIdentifier: 'https://tweakers.net/nieuws/162870', title: 'Samsung kondigt eerste tablet met 5g aan', body: 'Samsung heef zijn eerste tablet met 5g aangekondigd. Het gaat om een variant op de al bestaande Galaxy Tab S6, maar dan voorzien van Qualcomm X50-modem. Er gingen al maanden geruchten over de release van de tablet.', author: 'Arnoud Wokke', - publication_date: '2020-01-29T16:29:40Z', + publicationDate: '2020-01-29T16:29:40Z', url: 'https://tweakers.net/nieuws/162870/samsung-kondigt-eerste-tablet-met-5g-aan.html', rule: 7, diff --git a/src/newsreader/js/tests/homepage/reducers/rule.test.js b/src/newsreader/js/tests/homepage/reducers/rule.test.js index 0332a51..67e1f4c 100644 --- a/src/newsreader/js/tests/homepage/reducers/rule.test.js +++ b/src/newsreader/js/tests/homepage/reducers/rule.test.js @@ -87,12 +87,12 @@ describe('category reducer', () => { const post = { id: 2182, - remote_identifier: 'https://arstechnica.com/?p=1648871', + remoteIdentifier: 'https://arstechnica.com/?p=1648871', title: 'Tesla needs to fix Autopilot safety flaws, demands Senator Markey', body: 'It should be renamed and fitted with a real driver-monitoring system, he says.', author: 'Jonathan M. Gitlin', - publication_date: '2020-01-25T18:34:20Z', + publicationDate: '2020-01-25T18:34:20Z', url: 'https://arstechnica.com/?p=1648871', rule: 1, read: false, diff --git a/src/newsreader/js/tests/homepage/reducers/selected.test.js b/src/newsreader/js/tests/homepage/reducers/selected.test.js index 365143a..22a5e7e 100644 --- a/src/newsreader/js/tests/homepage/reducers/selected.test.js +++ b/src/newsreader/js/tests/homepage/reducers/selected.test.js @@ -214,25 +214,25 @@ describe('selected reducer', () => { const posts = { 2067: { id: 2067, - remote_identifier: 'https://arstechnica.com/?p=1648607', + remoteIdentifier: 'https://arstechnica.com/?p=1648607', title: 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', body: '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', author: 'Kyle Orland', - publication_date: '2020-01-24T19:50:12Z', + publicationDate: '2020-01-24T19:50:12Z', url: 'https://arstechnica.com/?p=1648607', rule: 4, read: false, }, 2141: { id: 2141, - remote_identifier: 'https://arstechnica.com/?p=1648757', + remoteIdentifier: 'https://arstechnica.com/?p=1648757', title: 'The most complete brain map ever is here: A fly’s “connectome”', body: 'It took 12 years and at least $40 million to chart a region about 250µm across.', author: 'WIRED', - publication_date: '2020-01-25T11:06:46Z', + publicationDate: '2020-01-25T11:06:46Z', url: 'https://arstechnica.com/?p=1648757', rule: 4, read: false, @@ -257,13 +257,13 @@ describe('selected reducer', () => { it('should return state after receiving a post', () => { const post = { id: 2067, - remote_identifier: 'https://arstechnica.com/?p=1648607', + remoteIdentifier: 'https://arstechnica.com/?p=1648607', title: 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', body: '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', author: 'Kyle Orland', - publication_date: '2020-01-24T19:50:12Z', + publicationDate: '2020-01-24T19:50:12Z', url: 'https://arstechnica.com/?p=1648607', rule: 4, read: false, @@ -283,13 +283,13 @@ describe('selected reducer', () => { it('should return state after selecting a post', () => { const post = { id: 2067, - remote_identifier: 'https://arstechnica.com/?p=1648607', + remoteIdentifier: 'https://arstechnica.com/?p=1648607', title: 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', body: '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', author: 'Kyle Orland', - publication_date: '2020-01-24T19:50:12Z', + publicationDate: '2020-01-24T19:50:12Z', url: 'https://arstechnica.com/?p=1648607', rule: 4, read: false, @@ -308,13 +308,13 @@ describe('selected reducer', () => { it('should return state after unselecting a post', () => { const post = { id: 2067, - remote_identifier: 'https://arstechnica.com/?p=1648607', + remoteIdentifier: 'https://arstechnica.com/?p=1648607', title: 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', body: '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', author: 'Kyle Orland', - publication_date: '2020-01-24T19:50:12Z', + publicationDate: '2020-01-24T19:50:12Z', url: 'https://arstechnica.com/?p=1648607', rule: 4, read: false, @@ -334,13 +334,13 @@ describe('selected reducer', () => { it('should return state after marking a post read', () => { const post = { id: 2067, - remote_identifier: 'https://arstechnica.com/?p=1648607', + remoteIdentifier: 'https://arstechnica.com/?p=1648607', title: 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', body: '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', author: 'Kyle Orland', - publication_date: '2020-01-24T19:50:12Z', + publicationDate: '2020-01-24T19:50:12Z', url: 'https://arstechnica.com/?p=1648607', rule: 4, read: false, diff --git a/src/newsreader/news/core/serializers.py b/src/newsreader/news/core/serializers.py index 791d873..e18070f 100644 --- a/src/newsreader/news/core/serializers.py +++ b/src/newsreader/news/core/serializers.py @@ -5,18 +5,21 @@ from newsreader.news.core.models import Category, Post class PostSerializer(serializers.ModelSerializer): + publicationDate = serializers.DateTimeField(source="publication_date", required=False) + remoteIdentifier = serializers.CharField(source="remote_identifier", required=False) + class Meta: model = Post fields = ( "id", - "remote_identifier", "title", "body", "author", - "publication_date", "url", "rule", "read", + "publicationDate", + "remoteIdentifier", ) diff --git a/src/newsreader/news/core/tests/endpoints/post/detail/tests.py b/src/newsreader/news/core/tests/endpoints/post/detail/tests.py index acc4bd1..bc184a3 100644 --- a/src/newsreader/news/core/tests/endpoints/post/detail/tests.py +++ b/src/newsreader/news/core/tests/endpoints/post/detail/tests.py @@ -28,10 +28,10 @@ class PostDetailViewTestCase(TestCase): self.assertTrue("title" in data) self.assertTrue("body" in data) self.assertTrue("author" in data) - self.assertTrue("publication_date" in data) + self.assertTrue("publicationDate" in data) self.assertTrue("url" in data) self.assertTrue("rule" in data) - self.assertTrue("remote_identifier" in data) + self.assertTrue("remoteIdentifier" in data) def test_not_known(self): response = self.client.get(reverse("api:posts-detail", args=[100])) From f9bce3507cef3841b464438360fbe54d8c65e23d Mon Sep 17 00:00:00 2001 From: Sonny Date: Sat, 1 Feb 2020 21:42:29 +0100 Subject: [PATCH 041/422] Redux actions refactor --- .../js/pages/homepage/actions/categories.js | 68 +++++++------------ .../js/pages/homepage/actions/posts.js | 10 +-- .../js/pages/homepage/actions/rules.js | 10 +-- .../js/pages/homepage/reducers/categories.js | 15 ++-- .../js/pages/homepage/reducers/posts.js | 16 +++-- .../js/pages/homepage/reducers/rules.js | 11 +-- .../js/pages/homepage/reducers/selected.js | 10 +-- .../tests/homepage/actions/category.test.js | 50 +++++++------- .../js/tests/homepage/actions/post.test.js | 20 +++--- .../js/tests/homepage/actions/rule.test.js | 20 +++--- .../tests/homepage/reducers/category.test.js | 20 +++--- .../js/tests/homepage/reducers/post.test.js | 13 ++-- .../js/tests/homepage/reducers/rule.test.js | 13 ++-- .../tests/homepage/reducers/selected.test.js | 36 ++++++++-- src/newsreader/js/utils.js | 10 +++ 15 files changed, 161 insertions(+), 161 deletions(-) diff --git a/src/newsreader/js/pages/homepage/actions/categories.js b/src/newsreader/js/pages/homepage/actions/categories.js index f30424f..0fc63a6 100644 --- a/src/newsreader/js/pages/homepage/actions/categories.js +++ b/src/newsreader/js/pages/homepage/actions/categories.js @@ -28,49 +28,6 @@ export const receiveCategories = categories => ({ export const requestCategory = () => ({ type: REQUEST_CATEGORY }); export const requestCategories = () => ({ type: REQUEST_CATEGORIES }); -export const fetchCategories = () => { - return dispatch => { - dispatch(requestCategories()); - - return fetch('/api/categories/') - .then(response => response.json()) - .then(json => { - const categories = {}; - - json.forEach(category => { - categories[category.id] = { ...category }; - }); - - dispatch(receiveCategories(categories)); - return json; - }) - .then(json => { - const promises = json.map(category => { - return fetch(`/api/categories/${category.id}/rules/`); - }); - - dispatch(requestRules()); - return Promise.all(promises); - }) - .then(responses => { - return Promise.all(responses.map(response => response.json())); - }) - .then(responseData => { - let rules = {}; - - responseData.forEach(json => { - const data = Object.values(json); - - data.forEach(item => { - rules = { ...rules, [item.id]: item }; - }); - }); - - dispatch(receiveRules(rules)); - }); - }; -}; - export const fetchCategory = category => { return (dispatch, getState) => { const { selected } = getState(); @@ -93,3 +50,28 @@ export const fetchCategory = category => { }); }; }; + +export const fetchCategories = () => { + return dispatch => { + dispatch(requestCategories()); + + return fetch('/api/categories/') + .then(response => response.json()) + .then(categories => { + dispatch(receiveCategories(categories)); + + return categories; + }) + .then(categories => { + dispatch(requestRules()); + + const promises = categories.map(category => { + return fetch(`/api/categories/${category.id}/rules/`); + }); + + return Promise.all(promises); + }) + .then(responses => Promise.all(responses.map(response => response.json()))) + .then(nestedRules => dispatch(receiveRules(nestedRules.flat()))); + }; +}; diff --git a/src/newsreader/js/pages/homepage/actions/posts.js b/src/newsreader/js/pages/homepage/actions/posts.js index 598dde1..d1fc79b 100644 --- a/src/newsreader/js/pages/homepage/actions/posts.js +++ b/src/newsreader/js/pages/homepage/actions/posts.js @@ -75,15 +75,7 @@ export const fetchPostsBySection = (section, page = false) => { return fetch(url) .then(response => response.json()) - .then(json => { - const posts = {}; - - json.results.forEach(post => { - posts[post.id] = post; - }); - - dispatch(receivePosts(posts, json.next)); - }) + .then(posts => dispatch(receivePosts(posts.results, posts.next))) .catch(error => { if (error instanceof TypeError) { console.log(`Unable to parse posts from request: ${error}`); diff --git a/src/newsreader/js/pages/homepage/actions/rules.js b/src/newsreader/js/pages/homepage/actions/rules.js index 41e2e06..0f45f1d 100644 --- a/src/newsreader/js/pages/homepage/actions/rules.js +++ b/src/newsreader/js/pages/homepage/actions/rules.js @@ -61,14 +61,6 @@ export const fetchRulesByCategory = category => { return fetch(`/api/categories/${category.id}/rules/`) .then(response => response.json()) - .then(responseData => { - const rules = {}; - - responseData.forEach(rule => { - rules[rule.id] = { ...rule }; - }); - - dispatch(receiveRules(rules)); - }); + .then(rules => dispatch(receiveRules(rules))); }; }; diff --git a/src/newsreader/js/pages/homepage/reducers/categories.js b/src/newsreader/js/pages/homepage/reducers/categories.js index a1f8961..612b98f 100644 --- a/src/newsreader/js/pages/homepage/reducers/categories.js +++ b/src/newsreader/js/pages/homepage/reducers/categories.js @@ -2,6 +2,8 @@ import { isEqual } from 'lodash'; import { CATEGORY_TYPE, RULE_TYPE } from '../constants.js'; +import { objectsFromArray } from '../../../utils.js'; + import { RECEIVE_CATEGORY, RECEIVE_CATEGORIES, @@ -26,13 +28,7 @@ export const categories = (state = { ...defaultState }, action) => { isFetching: false, }; case RECEIVE_CATEGORIES: - const receivedCategories = {}; - - Object.values({ ...action.categories }).forEach(category => { - receivedCategories[category.id] = { - ...category, - }; - }); + const receivedCategories = objectsFromArray(action.categories, 'id'); return { ...state, @@ -41,10 +37,7 @@ export const categories = (state = { ...defaultState }, action) => { }; case REQUEST_CATEGORIES: case REQUEST_CATEGORY: - return { - ...state, - isFetching: true, - }; + return { ...state, isFetching: true }; case MARK_POST_READ: let category = {}; diff --git a/src/newsreader/js/pages/homepage/reducers/posts.js b/src/newsreader/js/pages/homepage/reducers/posts.js index 4e7cb8c..220c59b 100644 --- a/src/newsreader/js/pages/homepage/reducers/posts.js +++ b/src/newsreader/js/pages/homepage/reducers/posts.js @@ -1,4 +1,6 @@ import { isEqual } from 'lodash'; + +import { objectsFromArray } from '../../../utils.js'; import { CATEGORY_TYPE, RULE_TYPE } from '../constants.js'; import { @@ -15,12 +17,6 @@ const defaultState = { items: {}, isFetching: false }; export const posts = (state = { ...defaultState }, action) => { switch (action.type) { - case RECEIVE_POSTS: - return { - ...state, - isFetching: false, - items: { ...state.items, ...action.posts }, - }; case REQUEST_POSTS: return { ...state, isFetching: true }; case RECEIVE_POST: @@ -28,6 +24,14 @@ export const posts = (state = { ...defaultState }, action) => { ...state, items: { ...state.items, [action.post.id]: { ...action.post } }, }; + case RECEIVE_POSTS: + const receivedItems = objectsFromArray(action.posts, 'id'); + + return { + ...state, + isFetching: false, + items: { ...state.items, ...receivedItems }, + }; case MARK_SECTION_READ: const updatedPosts = {}; let relatedPosts = []; diff --git a/src/newsreader/js/pages/homepage/reducers/rules.js b/src/newsreader/js/pages/homepage/reducers/rules.js index 0802576..ea3480c 100644 --- a/src/newsreader/js/pages/homepage/reducers/rules.js +++ b/src/newsreader/js/pages/homepage/reducers/rules.js @@ -1,5 +1,7 @@ import { isEqual } from 'lodash'; +import { objectsFromArray } from '../../../utils.js'; + import { CATEGORY_TYPE, RULE_TYPE } from '../constants.js'; import { @@ -17,14 +19,13 @@ export const rules = (state = { ...defaultState }, action) => { switch (action.type) { case REQUEST_RULE: case REQUEST_RULES: - return { - ...state, - isFetching: true, - }; + return { ...state, isFetching: true }; case RECEIVE_RULES: + const receivedItems = objectsFromArray(action.rules, 'id'); + return { ...state, - items: { ...state.items, ...action.rules }, + items: { ...state.items, ...receivedItems }, isFetching: false, }; case RECEIVE_RULE: diff --git a/src/newsreader/js/pages/homepage/reducers/selected.js b/src/newsreader/js/pages/homepage/reducers/selected.js index 632654d..babcb82 100644 --- a/src/newsreader/js/pages/homepage/reducers/selected.js +++ b/src/newsreader/js/pages/homepage/reducers/selected.js @@ -67,15 +67,9 @@ export const selected = (state = { ...defaultState }, action) => { ...state, }; case SELECT_POST: - return { - ...state, - post: action.post, - }; + return { ...state, post: action.post }; case UNSELECT_POST: - return { - ...state, - post: {}, - }; + return { ...state, post: {} }; case MARK_POST_READ: return { ...state, diff --git a/src/newsreader/js/tests/homepage/actions/category.test.js b/src/newsreader/js/tests/homepage/actions/category.test.js index 235de76..a6be5ad 100644 --- a/src/newsreader/js/tests/homepage/actions/category.test.js +++ b/src/newsreader/js/tests/homepage/actions/category.test.js @@ -96,22 +96,13 @@ describe('category actions', () => { }); it('should create multiple actions when fetching categories', () => { - const categories = { - 1: { id: 1, name: 'Tech', unread: 29 }, - 2: { id: 2, name: 'World news', unread: 956 }, - }; + const categories = [ + { id: 1, name: 'Tech', unread: 29 }, + { id: 2, name: 'World news', unread: 956 }, + ]; - const rules = { - 4: { - id: 4, - name: 'BBC', - url: 'http://feeds.bbci.co.uk/news/world/rss.xml', - favicon: - 'https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png', - category: 2, - unread: 345, - }, - 5: { + const rules = [ + { id: 5, name: 'Ars Technica', url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', @@ -119,19 +110,28 @@ describe('category actions', () => { category: 1, unread: 7, }, - }; + { + id: 6, + name: 'BBC', + url: 'http://feeds.bbci.co.uk/news/world/rss.xml', + favicon: + 'https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png', + category: 2, + unread: 345, + }, + ]; fetchMock .get('/api/categories/', { - body: Object.values({ ...categories }), + body: categories, headers: { 'content-type': 'application/json' }, }) .get('/api/categories/1/rules/', { - body: [{ ...rules[5] }], + body: [{ ...rules[0] }], headers: { 'content-type': 'application/json' }, }) .get('/api/categories/2/rules/', { - body: [{ ...rules[4] }], + body: [{ ...rules[1] }], headers: { 'content-type': 'application/json' }, }); @@ -161,8 +161,8 @@ describe('category actions', () => { unread: 0, }; - const rules = { - 1: { + const rules = [ + { id: 1, name: 'Ars Technica', url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', @@ -170,7 +170,7 @@ describe('category actions', () => { category: 1, unread: 200, }, - 2: { + { id: 2, name: 'Hacker News', url: 'https://news.ycombinator.com/rss', @@ -178,7 +178,7 @@ describe('category actions', () => { category: 1, unread: 350, }, - }; + ]; fetchMock .get('/api/categories/1', { @@ -186,7 +186,7 @@ describe('category actions', () => { headers: { 'content-type': 'application/json' }, }) .get('/api/categories/1/rules/', { - body: Object.values({ ...rules }), + body: rules, headers: { 'content-type': 'application/json' }, }); @@ -197,7 +197,7 @@ describe('category actions', () => { category: { ...category, unread: 500 }, }, { type: ruleActions.REQUEST_RULES }, - { type: ruleActions.RECEIVE_RULES, rules: { ...rules } }, + { type: ruleActions.RECEIVE_RULES, rules }, ]; const store = mockStore({ diff --git a/src/newsreader/js/tests/homepage/actions/post.test.js b/src/newsreader/js/tests/homepage/actions/post.test.js index 31bb666..65967b4 100644 --- a/src/newsreader/js/tests/homepage/actions/post.test.js +++ b/src/newsreader/js/tests/homepage/actions/post.test.js @@ -163,8 +163,8 @@ describe('rule actions', () => { }); it('should create multiple actions to fetch posts by rule', () => { - const posts = { - 2067: { + const posts = [ + { id: 2067, remoteIdentifier: 'https://arstechnica.com/?p=1648607', title: @@ -177,7 +177,7 @@ describe('rule actions', () => { rule: 4, read: false, }, - 2141: { + { id: 2141, remoteIdentifier: 'https://arstechnica.com/?p=1648757', title: 'The most complete brain map ever is here: A fly’s “connectome”', @@ -189,7 +189,7 @@ describe('rule actions', () => { rule: 4, read: false, }, - }; + ]; const rule = { id: 4, @@ -206,7 +206,7 @@ describe('rule actions', () => { count: 2, next: 'https://durp.com/api/rules/4/posts/?page=2&read=false', previous: null, - results: Object.values({ ...posts }), + results: posts, }, headers: { 'content-type': 'application/json' }, }); @@ -233,8 +233,8 @@ describe('rule actions', () => { }); it('should create multiple actions to fetch posts by category', () => { - const posts = { - 2067: { + const posts = [ + { id: 2067, remoteIdentifier: 'https://arstechnica.com/?p=1648607', title: @@ -247,7 +247,7 @@ describe('rule actions', () => { rule: 4, read: false, }, - 2141: { + { id: 2141, remoteIdentifier: 'https://arstechnica.com/?p=1648757', title: 'The most complete brain map ever is here: A fly’s “connectome”', @@ -259,7 +259,7 @@ describe('rule actions', () => { rule: 4, read: false, }, - }; + ]; const category = { id: 1, @@ -273,7 +273,7 @@ describe('rule actions', () => { count: 2, next: 'https://durp.com/api/categories/4/posts/?page=2&read=false', previous: null, - results: Object.values({ ...posts }), + results: posts, }, headers: { 'content-type': 'application/json' }, }); diff --git a/src/newsreader/js/tests/homepage/actions/rule.test.js b/src/newsreader/js/tests/homepage/actions/rule.test.js index 509938d..70a3a89 100644 --- a/src/newsreader/js/tests/homepage/actions/rule.test.js +++ b/src/newsreader/js/tests/homepage/actions/rule.test.js @@ -2,6 +2,8 @@ import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import fetchMock from 'fetch-mock'; +import { objectsFromArray } from '../../../utils.js'; + import * as actions from '../../../pages/homepage/actions/rules.js'; import * as constants from '../../../pages/homepage/constants.js'; import * as categoryActions from '../../../pages/homepage/actions/categories.js'; @@ -63,8 +65,8 @@ describe('rule actions', () => { }); it('should create an action to receive multiple rules', () => { - const rules = { - 1: { + const rules = [ + { id: 1, name: 'Test rule', unread: 100, @@ -72,7 +74,7 @@ describe('rule actions', () => { url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', favicon: 'https://cdn.arstechnica.net/favicon.ico', }, - 2: { + { id: 2, name: 'Test rule 2', unread: 50, @@ -80,7 +82,7 @@ describe('rule actions', () => { url: 'https://xkcd.com/atom.xml', favicon: null, }, - }; + ]; const expectedAction = { type: actions.RECEIVE_RULES, @@ -211,8 +213,8 @@ describe('rule actions', () => { unread: 0, }; - const rules = { - 1: { + const rules = [ + { id: 1, name: 'Ars Technica', url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', @@ -220,7 +222,7 @@ describe('rule actions', () => { category: 1, unread: 200, }, - 2: { + { id: 2, name: 'Hacker News', url: 'https://news.ycombinator.com/rss', @@ -228,10 +230,10 @@ describe('rule actions', () => { category: 1, unread: 350, }, - }; + ]; fetchMock.getOnce('/api/categories/1/rules/', { - body: Object.values({ ...rules }), + body: rules, headers: { 'content-type': 'application/json' }, }); diff --git a/src/newsreader/js/tests/homepage/reducers/category.test.js b/src/newsreader/js/tests/homepage/reducers/category.test.js index 2eeed65..f5c27ae 100644 --- a/src/newsreader/js/tests/homepage/reducers/category.test.js +++ b/src/newsreader/js/tests/homepage/reducers/category.test.js @@ -1,5 +1,7 @@ import { categories as reducer } from '../../../pages/homepage/reducers/categories.js'; +import { objectsFromArray } from '../../../utils.js'; + import * as actions from '../../../pages/homepage/actions/categories.js'; import * as postActions from '../../../pages/homepage/actions/posts.js'; import * as selectedActions from '../../../pages/homepage/actions/selected.js'; @@ -25,19 +27,15 @@ describe('category reducer', () => { }); it('should return state after receiving multiple categories', () => { - const receivedCategories = { - 0: { id: 9, name: 'Tech', unread: 291 }, - 1: { id: 2, name: 'World news', unread: 444 }, - }; + const receivedCategories = [ + { id: 9, name: 'Tech', unread: 291 }, + { id: 2, name: 'World news', unread: 444 }, + ]; + const action = { type: actions.RECEIVE_CATEGORIES, categories: receivedCategories }; - const items = {}; - - Object.values({ ...receivedCategories }).forEach(category => { - items[category.id] = category; - }); - - const expectedState = { ...defaultState, items }; + const expectedCategories = objectsFromArray(receivedCategories, 'id'); + const expectedState = { ...defaultState, items: expectedCategories }; expect(reducer(undefined, action)).toEqual(expectedState); }); diff --git a/src/newsreader/js/tests/homepage/reducers/post.test.js b/src/newsreader/js/tests/homepage/reducers/post.test.js index b54c3a1..ef4234a 100644 --- a/src/newsreader/js/tests/homepage/reducers/post.test.js +++ b/src/newsreader/js/tests/homepage/reducers/post.test.js @@ -1,5 +1,7 @@ import { posts as reducer } from '../../../pages/homepage/reducers/posts.js'; +import { objectsFromArray } from '../../../utils.js'; + import * as actions from '../../../pages/homepage/actions/posts.js'; import * as selectedActions from '../../../pages/homepage/actions/selected.js'; import * as constants from '../../../pages/homepage/constants.js'; @@ -45,8 +47,8 @@ describe('post actions', () => { }); it('should return state after receiving posts', () => { - const posts = { - 2067: { + const posts = [ + { id: 2067, remoteIdentifier: 'https://arstechnica.com/?p=1648607', title: @@ -59,7 +61,7 @@ describe('post actions', () => { rule: 4, read: false, }, - 2141: { + { id: 2141, remoteIdentifier: 'https://arstechnica.com/?p=1648757', title: 'The most complete brain map ever is here: A fly’s “connectome”', @@ -71,7 +73,7 @@ describe('post actions', () => { rule: 4, read: false, }, - }; + ]; const action = { type: actions.RECEIVE_POSTS, @@ -79,10 +81,11 @@ describe('post actions', () => { posts, }; + const expectedPosts = objectsFromArray(posts, 'id'); const expectedState = { ...defaultState, isFetching: false, - items: posts, + items: expectedPosts, }; expect(reducer(undefined, action)).toEqual(expectedState); diff --git a/src/newsreader/js/tests/homepage/reducers/rule.test.js b/src/newsreader/js/tests/homepage/reducers/rule.test.js index 67e1f4c..171c301 100644 --- a/src/newsreader/js/tests/homepage/reducers/rule.test.js +++ b/src/newsreader/js/tests/homepage/reducers/rule.test.js @@ -1,5 +1,7 @@ import { rules as reducer } from '../../../pages/homepage/reducers/rules.js'; +import { objectsFromArray } from '../../../utils.js'; + import * as actions from '../../../pages/homepage/actions/rules.js'; import * as postActions from '../../../pages/homepage/actions/posts.js'; import * as selectedActions from '../../../pages/homepage/actions/selected.js'; @@ -49,8 +51,8 @@ describe('category reducer', () => { }); it('should return state after receiving multiple rules', () => { - const rules = { - 1: { + const rules = [ + { id: 1, name: 'Test rule', unread: 100, @@ -58,7 +60,7 @@ describe('category reducer', () => { url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', favicon: 'https://cdn.arstechnica.net/favicon.ico', }, - 2: { + { id: 2, name: 'Another Test rule', unread: 444, @@ -66,11 +68,12 @@ describe('category reducer', () => { url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', favicon: 'https://cdn.arstechnica.net/favicon.ico', }, - }; + ]; const action = { type: actions.RECEIVE_RULES, rules }; - const expectedState = { ...defaultState, items: { ...rules } }; + const mappedRules = objectsFromArray(rules, 'id'); + const expectedState = { ...defaultState, items: { ...mappedRules } }; expect(reducer(undefined, action)).toEqual(expectedState); }); diff --git a/src/newsreader/js/tests/homepage/reducers/selected.test.js b/src/newsreader/js/tests/homepage/reducers/selected.test.js index 22a5e7e..215c6e1 100644 --- a/src/newsreader/js/tests/homepage/reducers/selected.test.js +++ b/src/newsreader/js/tests/homepage/reducers/selected.test.js @@ -211,8 +211,8 @@ describe('selected reducer', () => { }); it('should return state after receiving posts', () => { - const posts = { - 2067: { + const posts = [ + { id: 2067, remoteIdentifier: 'https://arstechnica.com/?p=1648607', title: @@ -225,7 +225,7 @@ describe('selected reducer', () => { rule: 4, read: false, }, - 2141: { + { id: 2141, remoteIdentifier: 'https://arstechnica.com/?p=1648757', title: 'The most complete brain map ever is here: A fly’s “connectome”', @@ -237,7 +237,7 @@ describe('selected reducer', () => { rule: 4, read: false, }, - }; + ]; const action = { type: postActions.RECEIVE_POSTS, @@ -254,7 +254,7 @@ describe('selected reducer', () => { expect(reducer(undefined, action)).toEqual(expectedState); }); - it('should return state after receiving a post', () => { + it('should return state after receiving a post which is selected', () => { const post = { id: 2067, remoteIdentifier: 'https://arstechnica.com/?p=1648607', @@ -280,6 +280,32 @@ describe('selected reducer', () => { expect(reducer(state, action)).toEqual(expectedState); }); + it('should return state after receiving a post with none selected', () => { + const post = { + id: 2067, + remoteIdentifier: 'https://arstechnica.com/?p=1648607', + title: + 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', + body: + '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', + author: 'Kyle Orland', + publicationDate: '2020-01-24T19:50:12Z', + url: 'https://arstechnica.com/?p=1648607', + rule: 4, + read: false, + }; + + const action = { + type: postActions.RECEIVE_POST, + post: { ...post, rule: 6 }, + }; + + const state = { ...defaultState, post: {} }; + const expectedState = { ...defaultState, post: {} }; + + expect(reducer(state, action)).toEqual(expectedState); + }); + it('should return state after selecting a post', () => { const post = { id: 2067, diff --git a/src/newsreader/js/utils.js b/src/newsreader/js/utils.js index 0794a1a..9db723e 100644 --- a/src/newsreader/js/utils.js +++ b/src/newsreader/js/utils.js @@ -12,3 +12,13 @@ export const formatDatetime = dateString => { return date.toLocaleDateString(locale, dateOptions); }; + +export const objectsFromArray = (array, key) => { + const arrayEntries = array + .filter(object => key in object) + .map(object => { + return [object[key], { ...object }]; + }); + + return Object.fromEntries(arrayEntries); +}; From 36076dbc40094d287126db07956c7dd1a69b79c5 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 2 Feb 2020 15:43:37 +0100 Subject: [PATCH 042/422] Some style changes --- .../accounts/templates/accounts/login.html | 2 +- .../js/pages/homepage/components/PostModal.js | 12 +++++------ .../homepage/components/feedlist/PostItem.js | 16 ++++++++------- .../scss/components/card/_card.scss | 2 +- .../scss/components/form/_form.scss | 3 ++- .../scss/pages/homepage/components/index.scss | 1 + .../components/post-block/_post-block.scss | 1 - .../pages/homepage/components/post/_post.scss | 20 +++++++++++++------ .../posts-header/_posts-header.scss | 12 ++--------- .../components/posts-info/_posts-info.scss | 14 +++++++++++++ .../homepage/components/posts-info/index.scss | 1 + .../posts-section/_post-section.scss | 19 ++++++++++++++++++ .../components/posts-section/index.scss | 13 +----------- .../homepage/components/posts/_posts.scss | 3 ++- 14 files changed, 73 insertions(+), 46 deletions(-) create mode 100644 src/newsreader/scss/pages/homepage/components/posts-info/_posts-info.scss create mode 100644 src/newsreader/scss/pages/homepage/components/posts-info/index.scss create mode 100644 src/newsreader/scss/pages/homepage/components/posts-section/_post-section.scss diff --git a/src/newsreader/accounts/templates/accounts/login.html b/src/newsreader/accounts/templates/accounts/login.html index f98a216..00b248e 100644 --- a/src/newsreader/accounts/templates/accounts/login.html +++ b/src/newsreader/accounts/templates/accounts/login.html @@ -8,7 +8,7 @@ {% block content %}
    -
    {% endblock %} diff --git a/src/newsreader/news/core/templates/core/homepage.html b/src/newsreader/news/core/templates/core/homepage.html index b513d7f..bddb5a3 100644 --- a/src/newsreader/news/core/templates/core/homepage.html +++ b/src/newsreader/news/core/templates/core/homepage.html @@ -2,14 +2,10 @@ {% load static %} -{% block head %} - -{% endblock %} - {% block content %} -
    +
    {% endblock %} {% block scripts %} - + {% endblock %} diff --git a/src/newsreader/news/core/tests/endpoints/category/list/tests.py b/src/newsreader/news/core/tests/endpoints/category/list/tests.py index f97884b..f58832e 100644 --- a/src/newsreader/news/core/tests/endpoints/category/list/tests.py +++ b/src/newsreader/news/core/tests/endpoints/category/list/tests.py @@ -488,11 +488,11 @@ class NestedCategoryPostView(TestCase): self.assertEquals(posts[0]["title"], "Second BBC post") self.assertEquals(posts[1]["title"], "First BBC post") - self.assertEquals(posts[2]["title"], "Second Reuters post") - self.assertEquals(posts[3]["title"], "First Reuters post") + self.assertEquals(posts[2]["title"], "Second Guardian post") + self.assertEquals(posts[3]["title"], "First Guardian post") - self.assertEquals(posts[4]["title"], "Second Guardian post") - self.assertEquals(posts[5]["title"], "First Guardian post") + self.assertEquals(posts[4]["title"], "Second Reuters post") + self.assertEquals(posts[5]["title"], "First Reuters post") def test_only_posts_from_category_are_returned(self): category = CategoryFactory.create(user=self.user) diff --git a/src/newsreader/news/core/tests/test_views.py b/src/newsreader/news/core/tests/test_views.py index 47381d2..ad1dc1d 100644 --- a/src/newsreader/news/core/tests/test_views.py +++ b/src/newsreader/news/core/tests/test_views.py @@ -27,7 +27,11 @@ class CategoryCreateViewTestCase(CategoryViewTestCase, TestCase): def test_creation(self): rules = CollectionRuleFactory.create_batch(size=4, user=self.user) - data = {"name": "new-category", "rules": [rule.pk for rule in rules]} + data = { + "name": "new-category", + "rules": [rule.pk for rule in rules], + "user": self.user.pk, + } response = self.client.post(self.url, data) self.assertEquals(response.status_code, 302) @@ -55,13 +59,29 @@ class CategoryCreateViewTestCase(CategoryViewTestCase, TestCase): size=3, user=self.user, category=None ) - data = {"name": "new-category", "rules": [rule.pk for rule in other_rules]} + data = { + "name": "new-category", + "rules": [rule.pk for rule in other_rules], + "user": self.user.pk, + } response = self.client.post(self.url, data) self.assertContains(response, "not one of the available choices") self.assertEquals(Category.objects.count(), 0) + def test_unique_together(self): + category = CategoryFactory(name="category", user=self.user) + + data = {"name": "category", "user": self.user.pk, "rules": []} + response = self.client.post(self.url, data) + + categories = Category.objects.all() + + self.assertContains(response, "already exists") + + self.assertCountEqual(categories, [category]) + class CategoryUpdateViewTestCase(CategoryViewTestCase, TestCase): def setUp(self): @@ -71,7 +91,7 @@ class CategoryUpdateViewTestCase(CategoryViewTestCase, TestCase): self.url = reverse("category-update", args=[self.category.pk]) def test_name_change(self): - data = {"name": "durp"} + data = {"name": "durp", "user": self.user.pk} self.client.post(self.url, data) self.category.refresh_from_db() @@ -80,7 +100,11 @@ class CategoryUpdateViewTestCase(CategoryViewTestCase, TestCase): def test_add_collection_rules(self): rules = CollectionRuleFactory.create_batch(size=4, user=self.user) - data = {"name": self.category.name, "rules": [rule.pk for rule in rules]} + data = { + "name": self.category.name, + "rules": [rule.pk for rule in rules], + "user": self.user.pk, + } self.client.post(self.url, data) @@ -100,7 +124,11 @@ class CategoryUpdateViewTestCase(CategoryViewTestCase, TestCase): self.assertCountEqual(self.category.rule_ids, [rule.pk for rule in current_rules]) - data = {"name": self.category.name, "rules": [rule.pk for rule in other_rules]} + data = { + "name": self.category.name, + "rules": [rule.pk for rule in other_rules], + "user": self.user.pk, + } self.client.post(self.url, data) @@ -116,7 +144,7 @@ class CategoryUpdateViewTestCase(CategoryViewTestCase, TestCase): self.assertCountEqual(self.category.rule_ids, [rule.pk for rule in current_rules]) - data = {"name": "durp"} + data = {"name": "durp", "user": self.user.pk} self.client.post(self.url, data) self.category.refresh_from_db() @@ -139,7 +167,7 @@ class CategoryUpdateViewTestCase(CategoryViewTestCase, TestCase): other_category = CategoryFactory(name="other category", user=other_user) other_category.rules.set([*other_rules]) - data = {"name": "durp"} + data = {"name": "durp", "user": other_user.pk} other_url = reverse("category-update", args=[other_category.pk]) response = self.client.post(other_url, data) @@ -161,7 +189,11 @@ class CategoryUpdateViewTestCase(CategoryViewTestCase, TestCase): self.assertCountEqual(self.category.rule_ids, [rule.pk for rule in current_rules]) - data = {"name": self.category.name, "rules": [rule.pk for rule in other_rules]} + data = { + "name": self.category.name, + "rules": [rule.pk for rule in other_rules], + "user": self.user.pk, + } response = self.client.post(self.url, data) @@ -172,3 +204,16 @@ class CategoryUpdateViewTestCase(CategoryViewTestCase, TestCase): other_category.refresh_from_db() self.assertCountEqual(other_category.rule_ids, [rule.pk for rule in other_rules]) + + def test_unique_together(self): + other_category = CategoryFactory(name="other category", user=self.user) + + url = reverse("category-update", args=[other_category.pk]) + data = {"name": "category", "user": self.user.pk, "rules": []} + response = self.client.post(url, data) + + categories = Category.objects.all() + + self.assertContains(response, "already exists") + + self.assertCountEqual(categories, [self.category, other_category]) diff --git a/src/newsreader/news/core/views.py b/src/newsreader/news/core/views.py index a0da62a..4832837 100644 --- a/src/newsreader/news/core/views.py +++ b/src/newsreader/news/core/views.py @@ -53,9 +53,7 @@ class CategoryDetailMixin: return context_data def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs["user"] = self.request.user - return kwargs + return {**super().get_form_kwargs(), "user": self.request.user} class CategoryListView(CategoryViewMixin, ListView): diff --git a/src/newsreader/scss/pages/rules/components/card/_card.scss b/src/newsreader/scss/components/card/_rule-card.scss similarity index 94% rename from src/newsreader/scss/pages/rules/components/card/_card.scss rename to src/newsreader/scss/components/card/_rule-card.scss index efc2418..5edb25b 100644 --- a/src/newsreader/scss/pages/rules/components/card/_card.scss +++ b/src/newsreader/scss/components/card/_rule-card.scss @@ -1,4 +1,6 @@ .card { + @extend .card; + &__header { & div { display: flex; diff --git a/src/newsreader/scss/components/card/index.scss b/src/newsreader/scss/components/card/index.scss index 484e154..149efa0 100644 --- a/src/newsreader/scss/components/card/index.scss +++ b/src/newsreader/scss/components/card/index.scss @@ -1 +1,2 @@ @import "card"; +@import "rule-card"; diff --git a/src/newsreader/scss/pages/homepage/components/category/_category.scss b/src/newsreader/scss/components/category/_category.scss similarity index 100% rename from src/newsreader/scss/pages/homepage/components/category/_category.scss rename to src/newsreader/scss/components/category/_category.scss diff --git a/src/newsreader/scss/pages/homepage/components/category/index.scss b/src/newsreader/scss/components/category/index.scss similarity index 100% rename from src/newsreader/scss/pages/homepage/components/category/index.scss rename to src/newsreader/scss/components/category/index.scss diff --git a/src/newsreader/scss/components/content/_content.scss b/src/newsreader/scss/components/content/_content.scss deleted file mode 100644 index 1bb58ca..0000000 --- a/src/newsreader/scss/components/content/_content.scss +++ /dev/null @@ -1,5 +0,0 @@ -.content { - display: flex; - flex-direction: column; - align-items: center; -} diff --git a/src/newsreader/scss/components/content/index.scss b/src/newsreader/scss/components/content/index.scss deleted file mode 100644 index b424282..0000000 --- a/src/newsreader/scss/components/content/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "content"; diff --git a/src/newsreader/scss/components/errorlist/_errorlist.scss b/src/newsreader/scss/components/errorlist/_errorlist.scss index 393899f..006dafb 100644 --- a/src/newsreader/scss/components/errorlist/_errorlist.scss +++ b/src/newsreader/scss/components/errorlist/_errorlist.scss @@ -1,20 +1,23 @@ .errorlist { @extend .list; - padding: 10px; margin: 5px 0; + padding: 0; - background-color: $error-red; color: $white; list-style: disc; list-style-position: inside; - & li { - margin: 15px 0 0 0; + &__item { + margin: 10px 0; + padding: 10px; - &:first-child { - margin: 0; - } + background-color: $error-red; + border-radius: 5px; + } + + & li { + @extend .errorlist__item; } } diff --git a/src/newsreader/scss/pages/register/components/activation-form/_activation-form.scss b/src/newsreader/scss/components/form/_activation-form.scss similarity index 100% rename from src/newsreader/scss/pages/register/components/activation-form/_activation-form.scss rename to src/newsreader/scss/components/form/_activation-form.scss diff --git a/src/newsreader/scss/pages/category/components/category-form/_category-form.scss b/src/newsreader/scss/components/form/_category-form.scss similarity index 91% rename from src/newsreader/scss/pages/category/components/category-form/_category-form.scss rename to src/newsreader/scss/components/form/_category-form.scss index b6ef175..8132ed2 100644 --- a/src/newsreader/scss/pages/category/components/category-form/_category-form.scss +++ b/src/newsreader/scss/components/form/_category-form.scss @@ -1,8 +1,9 @@ .category-form { + @extend .form; + margin: 20px 0; &__section:last-child { - & .category-form__fieldset { display: flex; flex-direction: row; diff --git a/src/newsreader/scss/pages/import/components/import-form/_import-form.scss b/src/newsreader/scss/components/form/_import-form.scss similarity index 100% rename from src/newsreader/scss/pages/import/components/import-form/_import-form.scss rename to src/newsreader/scss/components/form/_import-form.scss diff --git a/src/newsreader/scss/pages/login/components/form/_form.scss b/src/newsreader/scss/components/form/_login-form.scss similarity index 100% rename from src/newsreader/scss/pages/login/components/form/_form.scss rename to src/newsreader/scss/components/form/_login-form.scss diff --git a/src/newsreader/scss/pages/password-reset/components/password-reset-confirm-form/_password-reset-confirm-form.scss b/src/newsreader/scss/components/form/_password-reset-confirm-form.scss similarity index 100% rename from src/newsreader/scss/pages/password-reset/components/password-reset-confirm-form/_password-reset-confirm-form.scss rename to src/newsreader/scss/components/form/_password-reset-confirm-form.scss diff --git a/src/newsreader/scss/pages/password-reset/components/password-reset-form/_password-reset-form.scss b/src/newsreader/scss/components/form/_password-reset-form.scss similarity index 100% rename from src/newsreader/scss/pages/password-reset/components/password-reset-form/_password-reset-form.scss rename to src/newsreader/scss/components/form/_password-reset-form.scss diff --git a/src/newsreader/scss/pages/register/components/register-form/_register-form.scss b/src/newsreader/scss/components/form/_register-form.scss similarity index 100% rename from src/newsreader/scss/pages/register/components/register-form/_register-form.scss rename to src/newsreader/scss/components/form/_register-form.scss diff --git a/src/newsreader/scss/pages/rule/components/rule-form/_rule-form.scss b/src/newsreader/scss/components/form/_rule-form.scss similarity index 83% rename from src/newsreader/scss/pages/rule/components/rule-form/_rule-form.scss rename to src/newsreader/scss/components/form/_rule-form.scss index 95a2388..82651aa 100644 --- a/src/newsreader/scss/pages/rule/components/rule-form/_rule-form.scss +++ b/src/newsreader/scss/components/form/_rule-form.scss @@ -2,7 +2,6 @@ margin: 20px 0; &__section:last-child { - & .rule-form__fieldset { display: flex; flex-direction: row; @@ -10,18 +9,17 @@ } } - &__select[name=category] { + #id_category { width: 50%; padding: 0 10px; } - &__select[name=timezone] { + #id_timezone { max-height: 200px; width: 50%; margin: 0 15px; padding: 0 10px; - } } diff --git a/src/newsreader/scss/components/form/index.scss b/src/newsreader/scss/components/form/index.scss index dc477a7..2c70cdd 100644 --- a/src/newsreader/scss/components/form/index.scss +++ b/src/newsreader/scss/components/form/index.scss @@ -1 +1,12 @@ @import "form"; + +@import "category-form"; +@import "rule-form"; +@import "import-form"; + +@import "login-form"; +@import "activation-form"; +@import "register-form"; + +@import "password-reset-form"; +@import "password-reset-confirm-form"; diff --git a/src/newsreader/scss/components/index.scss b/src/newsreader/scss/components/index.scss index 6a18d4a..4bddb31 100644 --- a/src/newsreader/scss/components/index.scss +++ b/src/newsreader/scss/components/index.scss @@ -1,13 +1,26 @@ -@import "./body/index"; -@import "./form/index"; -@import "./main/index"; -@import "./navbar/index"; -@import "./loading-indicator/index"; -@import "./modal/index"; -@import "./card/index"; -@import "./list/index"; -@import "./content/index"; -@import "./messages/index"; -@import "./section/index"; -@import "./errorlist/index"; -@import "./fieldset/index"; +@import "body/index"; +@import "form/index"; +@import "main/index"; +@import "navbar/index"; +@import "loading-indicator/index"; + +@import "modal/index"; + +@import "card/index"; +@import "list/index"; +@import "messages/index"; +@import "section/index"; +@import "errorlist/index"; +@import "fieldset/index"; +@import "sidebar/index"; + +@import "rules/index"; +@import "category/index"; + +@import "post/index"; +@import "post-block/index"; +@import "post-message/index"; +@import "posts/index"; +@import "posts-header/index"; +@import "posts-info/index"; +@import "posts-section/index"; diff --git a/src/newsreader/scss/components/main/_main.scss b/src/newsreader/scss/components/main/_main.scss index 1c4ed2e..5d0143f 100644 --- a/src/newsreader/scss/components/main/_main.scss +++ b/src/newsreader/scss/components/main/_main.scss @@ -1,4 +1,7 @@ .main { - margin: 1% 10% 5% 10%; - background-color: $white; + display: flex; + flex-direction: column; + align-items: center; + + margin: 20px 0; } diff --git a/src/newsreader/scss/components/messages/_messages.scss b/src/newsreader/scss/components/messages/_messages.scss index 11b53b8..56af888 100644 --- a/src/newsreader/scss/components/messages/_messages.scss +++ b/src/newsreader/scss/components/messages/_messages.scss @@ -1,16 +1,32 @@ .messages { display: flex; flex-direction: column; - - width: 100%; + align-items: center; margin: 5px 0 20px 0; - padding: 15px 0; color: $white; - background-color: $error-red; &__item { - padding: 0 30px; + width: 80%; + + padding: 20px 15px; + margin: 5px 0; + + border-radius: 5px; + + background-color: $focus-blue; + + &--error { + background-color: $error-red; + } + + &--warning { + background-color: $light-orange; + } + + &--success { + background-color: $success-green; + } } } diff --git a/src/newsreader/scss/components/modal/_modal.scss b/src/newsreader/scss/components/modal/_modal.scss index 16e8d1d..93fe54f 100644 --- a/src/newsreader/scss/components/modal/_modal.scss +++ b/src/newsreader/scss/components/modal/_modal.scss @@ -1,12 +1,42 @@ .modal { display: flex; flex-direction: column; + align-items: center; position: fixed; - width: 100%; height: 100%; top: 0; background-color: $dark; + + &__item { + display: flex; + flex-direction: column; + align-self: center; + + margin: 20px 0; + padding: 20px; + + width: 60%; + + border-radius: 5px; + background-color: $white; + } + + &__header { + padding: 5px 20px; + } + + &__content { + padding: 10px 30px; + } + + &__footer { + display: flex; + flex-direction: row; + justify-content: space-between; + + padding: 10px; + } } diff --git a/src/newsreader/scss/components/modal/_post-modal.scss b/src/newsreader/scss/components/modal/_post-modal.scss new file mode 100644 index 0000000..f357d77 --- /dev/null +++ b/src/newsreader/scss/components/modal/_post-modal.scss @@ -0,0 +1,9 @@ +.post-modal { + @extend .modal; + + margin: 0; + padding: 0; + + border-radius: 0; + cursor: pointer; +} diff --git a/src/newsreader/scss/components/modal/index.scss b/src/newsreader/scss/components/modal/index.scss index bcb7d8e..d84836a 100644 --- a/src/newsreader/scss/components/modal/index.scss +++ b/src/newsreader/scss/components/modal/index.scss @@ -1 +1,3 @@ @import "modal"; + +@import "post-modal"; diff --git a/src/newsreader/scss/pages/homepage/components/post-block/_post-block.scss b/src/newsreader/scss/components/post-block/_post-block.scss similarity index 86% rename from src/newsreader/scss/pages/homepage/components/post-block/_post-block.scss rename to src/newsreader/scss/components/post-block/_post-block.scss index e694de0..c65352b 100644 --- a/src/newsreader/scss/pages/homepage/components/post-block/_post-block.scss +++ b/src/newsreader/scss/components/post-block/_post-block.scss @@ -1,6 +1,7 @@ .post-block { display: flex; flex-direction: column; + align-items: center; width: 70%; margin: 0 0 2% 0; diff --git a/src/newsreader/scss/pages/homepage/components/post-block/index.scss b/src/newsreader/scss/components/post-block/index.scss similarity index 100% rename from src/newsreader/scss/pages/homepage/components/post-block/index.scss rename to src/newsreader/scss/components/post-block/index.scss diff --git a/src/newsreader/scss/pages/homepage/components/post-message/_post-message.scss b/src/newsreader/scss/components/post-message/_post-message.scss similarity index 100% rename from src/newsreader/scss/pages/homepage/components/post-message/_post-message.scss rename to src/newsreader/scss/components/post-message/_post-message.scss diff --git a/src/newsreader/scss/pages/homepage/components/post-message/index.scss b/src/newsreader/scss/components/post-message/index.scss similarity index 100% rename from src/newsreader/scss/pages/homepage/components/post-message/index.scss rename to src/newsreader/scss/components/post-message/index.scss diff --git a/src/newsreader/scss/pages/homepage/components/post/_post.scss b/src/newsreader/scss/components/post/_post.scss similarity index 95% rename from src/newsreader/scss/pages/homepage/components/post/_post.scss rename to src/newsreader/scss/components/post/_post.scss index 35c557e..bcf92a6 100644 --- a/src/newsreader/scss/pages/homepage/components/post/_post.scss +++ b/src/newsreader/scss/components/post/_post.scss @@ -15,6 +15,8 @@ background-color: $white; + cursor: initial; + &__header { display: flex; flex-direction: column; @@ -58,7 +60,11 @@ font-size: 18px; & p { - padding: 20px 0 0 0; + padding: 10px 0; + } + + & h1, h2, h3 { + margin: 20px 0 5px 0; } & img { diff --git a/src/newsreader/scss/pages/homepage/components/post/index.scss b/src/newsreader/scss/components/post/index.scss similarity index 100% rename from src/newsreader/scss/pages/homepage/components/post/index.scss rename to src/newsreader/scss/components/post/index.scss diff --git a/src/newsreader/scss/pages/homepage/components/posts-header/_posts-header.scss b/src/newsreader/scss/components/posts-header/_posts-header.scss similarity index 100% rename from src/newsreader/scss/pages/homepage/components/posts-header/_posts-header.scss rename to src/newsreader/scss/components/posts-header/_posts-header.scss diff --git a/src/newsreader/scss/pages/homepage/components/posts-header/index.scss b/src/newsreader/scss/components/posts-header/index.scss similarity index 100% rename from src/newsreader/scss/pages/homepage/components/posts-header/index.scss rename to src/newsreader/scss/components/posts-header/index.scss diff --git a/src/newsreader/scss/pages/homepage/components/posts-info/_posts-info.scss b/src/newsreader/scss/components/posts-info/_posts-info.scss similarity index 100% rename from src/newsreader/scss/pages/homepage/components/posts-info/_posts-info.scss rename to src/newsreader/scss/components/posts-info/_posts-info.scss diff --git a/src/newsreader/scss/pages/homepage/components/posts-info/index.scss b/src/newsreader/scss/components/posts-info/index.scss similarity index 100% rename from src/newsreader/scss/pages/homepage/components/posts-info/index.scss rename to src/newsreader/scss/components/posts-info/index.scss diff --git a/src/newsreader/scss/pages/homepage/components/posts-section/_post-section.scss b/src/newsreader/scss/components/posts-section/_post-section.scss similarity index 95% rename from src/newsreader/scss/pages/homepage/components/posts-section/_post-section.scss rename to src/newsreader/scss/components/posts-section/_post-section.scss index 902e1a2..1c40bec 100644 --- a/src/newsreader/scss/pages/homepage/components/posts-section/_post-section.scss +++ b/src/newsreader/scss/components/posts-section/_post-section.scss @@ -1,6 +1,7 @@ .posts-section { display: flex; flex-direction: column; + width: 95%; margin: 20px; padding: 10px; diff --git a/src/newsreader/scss/pages/homepage/components/posts-section/index.scss b/src/newsreader/scss/components/posts-section/index.scss similarity index 100% rename from src/newsreader/scss/pages/homepage/components/posts-section/index.scss rename to src/newsreader/scss/components/posts-section/index.scss diff --git a/src/newsreader/scss/pages/homepage/components/posts/_posts.scss b/src/newsreader/scss/components/posts/_posts.scss similarity index 100% rename from src/newsreader/scss/pages/homepage/components/posts/_posts.scss rename to src/newsreader/scss/components/posts/_posts.scss diff --git a/src/newsreader/scss/pages/homepage/components/posts/index.scss b/src/newsreader/scss/components/posts/index.scss similarity index 100% rename from src/newsreader/scss/pages/homepage/components/posts/index.scss rename to src/newsreader/scss/components/posts/index.scss diff --git a/src/newsreader/scss/pages/homepage/components/rules/_rules.scss b/src/newsreader/scss/components/rules/_rules.scss similarity index 65% rename from src/newsreader/scss/pages/homepage/components/rules/_rules.scss rename to src/newsreader/scss/components/rules/_rules.scss index ca12c9c..a1e9f67 100644 --- a/src/newsreader/scss/pages/homepage/components/rules/_rules.scss +++ b/src/newsreader/scss/components/rules/_rules.scss @@ -1,4 +1,6 @@ .rules { + padding: 0; + &__item { display: flex; justify-content: space-between; @@ -25,4 +27,20 @@ background-color: darken($azureish-white, +10%); } } + + &__info { + display: flex; + align-items: center; + width: 80%; + } + + &__title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + & .badge { + display: flex; + } } diff --git a/src/newsreader/scss/pages/homepage/components/rules/index.scss b/src/newsreader/scss/components/rules/index.scss similarity index 100% rename from src/newsreader/scss/pages/homepage/components/rules/index.scss rename to src/newsreader/scss/components/rules/index.scss diff --git a/src/newsreader/scss/components/sidebar/_sidebar.scss b/src/newsreader/scss/components/sidebar/_sidebar.scss new file mode 100644 index 0000000..feac44d --- /dev/null +++ b/src/newsreader/scss/components/sidebar/_sidebar.scss @@ -0,0 +1,26 @@ +.sidebar { + display: flex; + flex-direction: column; + align-items: center; + align-self: start; + + position: sticky; + top: 5%; + + width: 20%; + + &__nav { + width: 100%; + max-height: 80vh; + overflow: auto; + + list-style: none; + border-radius: 5px; + + font-family: $sidebar-font; + + &__item { + padding: 2px 10px 5px 10px; + } + } +} diff --git a/src/newsreader/scss/pages/homepage/components/sidebar/index.scss b/src/newsreader/scss/components/sidebar/index.scss similarity index 100% rename from src/newsreader/scss/pages/homepage/components/sidebar/index.scss rename to src/newsreader/scss/components/sidebar/index.scss diff --git a/src/newsreader/scss/pages/homepage/elements/badge/_badge.scss b/src/newsreader/scss/elements/badge/_badge.scss similarity index 100% rename from src/newsreader/scss/pages/homepage/elements/badge/_badge.scss rename to src/newsreader/scss/elements/badge/_badge.scss diff --git a/src/newsreader/scss/pages/homepage/elements/badge/index.scss b/src/newsreader/scss/elements/badge/index.scss similarity index 100% rename from src/newsreader/scss/pages/homepage/elements/badge/index.scss rename to src/newsreader/scss/elements/badge/index.scss diff --git a/src/newsreader/scss/pages/homepage/components/read-button/_read-button.scss b/src/newsreader/scss/elements/button/_read-button.scss similarity index 88% rename from src/newsreader/scss/pages/homepage/components/read-button/_read-button.scss rename to src/newsreader/scss/elements/button/_read-button.scss index a8eab4c..940d895 100644 --- a/src/newsreader/scss/pages/homepage/components/read-button/_read-button.scss +++ b/src/newsreader/scss/elements/button/_read-button.scss @@ -1,4 +1,6 @@ .read-button { + @extend .button; + margin: 20px 0 0 0; color: $white; diff --git a/src/newsreader/scss/elements/button/index.scss b/src/newsreader/scss/elements/button/index.scss index ac3b5de..a9b2ec7 100644 --- a/src/newsreader/scss/elements/button/index.scss +++ b/src/newsreader/scss/elements/button/index.scss @@ -1 +1,2 @@ @import "button"; +@import "_read-button"; diff --git a/src/newsreader/scss/elements/index.scss b/src/newsreader/scss/elements/index.scss index 46a8bbd..f0d7be3 100644 --- a/src/newsreader/scss/elements/index.scss +++ b/src/newsreader/scss/elements/index.scss @@ -7,3 +7,4 @@ @import "input/index"; @import "label/index"; @import "help-text/index"; +@import "badge/index"; diff --git a/src/newsreader/scss/index.scss b/src/newsreader/scss/index.scss new file mode 100644 index 0000000..1fa6f17 --- /dev/null +++ b/src/newsreader/scss/index.scss @@ -0,0 +1,5 @@ +@import "partials/index"; +@import "components/index"; +@import "elements/index"; + +@import "pages/index"; diff --git a/src/newsreader/scss/pages/activate/components/index.scss b/src/newsreader/scss/pages/activate/components/index.scss deleted file mode 100644 index e69de29..0000000 diff --git a/src/newsreader/scss/pages/activate/elements/index.scss b/src/newsreader/scss/pages/activate/elements/index.scss deleted file mode 100644 index e69de29..0000000 diff --git a/src/newsreader/scss/pages/activate/index.scss b/src/newsreader/scss/pages/activate/index.scss deleted file mode 100644 index 16b6493..0000000 --- a/src/newsreader/scss/pages/activate/index.scss +++ /dev/null @@ -1,8 +0,0 @@ -// General imports -@import "../../partials/variables"; -@import "../../components/index"; -@import "../../elements/index"; - -// Page specific -@import "./components/index"; -@import "./elements/index"; diff --git a/src/newsreader/scss/pages/categories/components/card/_card.scss b/src/newsreader/scss/pages/categories/components/card/_card.scss deleted file mode 100644 index 79d3c89..0000000 --- a/src/newsreader/scss/pages/categories/components/card/_card.scss +++ /dev/null @@ -1,5 +0,0 @@ -.card { - &__footer > *:last-child { - margin: 0 0 0 10px; - } -} diff --git a/src/newsreader/scss/pages/categories/components/card/index.scss b/src/newsreader/scss/pages/categories/components/card/index.scss deleted file mode 100644 index 484e154..0000000 --- a/src/newsreader/scss/pages/categories/components/card/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "card"; diff --git a/src/newsreader/scss/pages/categories/components/category-modal/_category-modal.scss b/src/newsreader/scss/pages/categories/components/category-modal/_category-modal.scss deleted file mode 100644 index 289c09f..0000000 --- a/src/newsreader/scss/pages/categories/components/category-modal/_category-modal.scss +++ /dev/null @@ -1,27 +0,0 @@ -.category-modal { - display: flex; - flex-direction: column; - align-self: center; - - margin: 20px 0; - width: 50%; - - border-radius: 2px; - background-color: $white; - - &__header { - padding: 5px 20px; - } - - &__content { - padding: 10px 30px; - } - - &__footer { - display: flex; - flex-direction: row; - justify-content: space-between; - - padding: 10px; - } -} diff --git a/src/newsreader/scss/pages/categories/components/category-modal/index.scss b/src/newsreader/scss/pages/categories/components/category-modal/index.scss deleted file mode 100644 index 0126261..0000000 --- a/src/newsreader/scss/pages/categories/components/category-modal/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "category-modal"; diff --git a/src/newsreader/scss/pages/categories/components/index.scss b/src/newsreader/scss/pages/categories/components/index.scss deleted file mode 100644 index 2890f60..0000000 --- a/src/newsreader/scss/pages/categories/components/index.scss +++ /dev/null @@ -1,2 +0,0 @@ -@import "card/index"; -@import "category-modal/index"; diff --git a/src/newsreader/scss/pages/categories/elements/index.scss b/src/newsreader/scss/pages/categories/elements/index.scss deleted file mode 100644 index e69de29..0000000 diff --git a/src/newsreader/scss/pages/categories/index.scss b/src/newsreader/scss/pages/categories/index.scss index 16b6493..b683e0e 100644 --- a/src/newsreader/scss/pages/categories/index.scss +++ b/src/newsreader/scss/pages/categories/index.scss @@ -1,8 +1,7 @@ -// General imports -@import "../../partials/variables"; -@import "../../components/index"; -@import "../../elements/index"; - -// Page specific -@import "./components/index"; -@import "./elements/index"; +#categories--page { + & .card { + &__footer > *:last-child { + margin: 0 0 0 10px; + } + } +} diff --git a/src/newsreader/scss/pages/category/components/category-form/index.scss b/src/newsreader/scss/pages/category/components/category-form/index.scss deleted file mode 100644 index 715c81e..0000000 --- a/src/newsreader/scss/pages/category/components/category-form/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "category-form"; diff --git a/src/newsreader/scss/pages/category/components/index.scss b/src/newsreader/scss/pages/category/components/index.scss deleted file mode 100644 index aa4af19..0000000 --- a/src/newsreader/scss/pages/category/components/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "category-form/index"; diff --git a/src/newsreader/scss/pages/category/elements/index.scss b/src/newsreader/scss/pages/category/elements/index.scss deleted file mode 100644 index e69de29..0000000 diff --git a/src/newsreader/scss/pages/category/index.scss b/src/newsreader/scss/pages/category/index.scss index 16b6493..ae62be1 100644 --- a/src/newsreader/scss/pages/category/index.scss +++ b/src/newsreader/scss/pages/category/index.scss @@ -1,8 +1,2 @@ -// General imports -@import "../../partials/variables"; -@import "../../components/index"; -@import "../../elements/index"; - -// Page specific -@import "./components/index"; -@import "./elements/index"; +#category--page { +} diff --git a/src/newsreader/scss/pages/homepage/components/categories/_categories.scss b/src/newsreader/scss/pages/homepage/components/categories/_categories.scss deleted file mode 100644 index 002a66a..0000000 --- a/src/newsreader/scss/pages/homepage/components/categories/_categories.scss +++ /dev/null @@ -1,24 +0,0 @@ -.categories { - display: flex; - flex-direction: column; - align-items: center; - - width: 90%; - - font-family: $sidebar-font; - border-radius: 2px; - - & ul { - margin: 0; - padding: 0; - - width: 100%; - - list-style: none; - border-radius: 5px; - } - - &__item { - padding: 2px 10px 5px 10px; - } -} diff --git a/src/newsreader/scss/pages/homepage/components/categories/index.scss b/src/newsreader/scss/pages/homepage/components/categories/index.scss deleted file mode 100644 index 0eebf91..0000000 --- a/src/newsreader/scss/pages/homepage/components/categories/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "categories"; diff --git a/src/newsreader/scss/pages/homepage/components/content/_content.scss b/src/newsreader/scss/pages/homepage/components/content/_content.scss deleted file mode 100644 index 9b9efb9..0000000 --- a/src/newsreader/scss/pages/homepage/components/content/_content.scss +++ /dev/null @@ -1,7 +0,0 @@ -.content { - display: flex; - flex-direction: column; - align-items: center; - - margin: 2% 0 0 0; -} diff --git a/src/newsreader/scss/pages/homepage/components/content/index.scss b/src/newsreader/scss/pages/homepage/components/content/index.scss deleted file mode 100644 index b424282..0000000 --- a/src/newsreader/scss/pages/homepage/components/content/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "content"; diff --git a/src/newsreader/scss/pages/homepage/components/index.scss b/src/newsreader/scss/pages/homepage/components/index.scss deleted file mode 100644 index 2a0a85e..0000000 --- a/src/newsreader/scss/pages/homepage/components/index.scss +++ /dev/null @@ -1,18 +0,0 @@ -@import "content/index"; -@import "main/index"; - -@import "sidebar/index"; -@import "categories/index"; -@import "category/index"; - -@import "rules/index"; -@import "rule/index"; - -@import "post-block/index"; -@import "posts-section/index"; -@import "posts/index"; -@import "posts-header/index"; -@import "posts-info/index"; -@import "post/index"; -@import "post-message/index"; -@import "read-button/index"; diff --git a/src/newsreader/scss/pages/homepage/components/main/_main.scss b/src/newsreader/scss/pages/homepage/components/main/_main.scss deleted file mode 100644 index 42cb2d5..0000000 --- a/src/newsreader/scss/pages/homepage/components/main/_main.scss +++ /dev/null @@ -1,12 +0,0 @@ -.main { - display: flex; - flex-direction: row; - width: 100%; - - margin: 0; - background-color: initial; - - &--centered { - justify-content: center; - } -} diff --git a/src/newsreader/scss/pages/homepage/components/main/index.scss b/src/newsreader/scss/pages/homepage/components/main/index.scss deleted file mode 100644 index bdb4ce0..0000000 --- a/src/newsreader/scss/pages/homepage/components/main/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "main"; diff --git a/src/newsreader/scss/pages/homepage/components/read-button/index.scss b/src/newsreader/scss/pages/homepage/components/read-button/index.scss deleted file mode 100644 index 8e49454..0000000 --- a/src/newsreader/scss/pages/homepage/components/read-button/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'read-button'; diff --git a/src/newsreader/scss/pages/homepage/components/rule/_rule.scss b/src/newsreader/scss/pages/homepage/components/rule/_rule.scss deleted file mode 100644 index ba34bf6..0000000 --- a/src/newsreader/scss/pages/homepage/components/rule/_rule.scss +++ /dev/null @@ -1,15 +0,0 @@ -.rule { - display: flex; - align-items: center; - width: 80%; - - &__title { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - & span { - display: flex; - } -} diff --git a/src/newsreader/scss/pages/homepage/components/rule/index.scss b/src/newsreader/scss/pages/homepage/components/rule/index.scss deleted file mode 100644 index 7ec839a..0000000 --- a/src/newsreader/scss/pages/homepage/components/rule/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "rule"; diff --git a/src/newsreader/scss/pages/homepage/components/sidebar/_sidebar.scss b/src/newsreader/scss/pages/homepage/components/sidebar/_sidebar.scss deleted file mode 100644 index 5c6575b..0000000 --- a/src/newsreader/scss/pages/homepage/components/sidebar/_sidebar.scss +++ /dev/null @@ -1,11 +0,0 @@ -.sidebar { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - align-self: flex-start; - - position: sticky; - top: 5%; - width: 20%; -} diff --git a/src/newsreader/scss/pages/homepage/elements/index.scss b/src/newsreader/scss/pages/homepage/elements/index.scss deleted file mode 100644 index 66bce62..0000000 --- a/src/newsreader/scss/pages/homepage/elements/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "badge/index"; diff --git a/src/newsreader/scss/pages/homepage/index.scss b/src/newsreader/scss/pages/homepage/index.scss index 16b6493..30f5a50 100644 --- a/src/newsreader/scss/pages/homepage/index.scss +++ b/src/newsreader/scss/pages/homepage/index.scss @@ -1,8 +1,9 @@ -// General imports -@import "../../partials/variables"; -@import "../../components/index"; -@import "../../elements/index"; +#homepage--page { + display: flex; + flex-direction: row; + align-items: initial; + width: 100%; -// Page specific -@import "./components/index"; -@import "./elements/index"; + margin: 20px 0 0 0; + background-color: initial; +} diff --git a/src/newsreader/scss/pages/import/components/import-form/index.scss b/src/newsreader/scss/pages/import/components/import-form/index.scss deleted file mode 100644 index b6407f2..0000000 --- a/src/newsreader/scss/pages/import/components/import-form/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "import-form"; diff --git a/src/newsreader/scss/pages/import/components/index.scss b/src/newsreader/scss/pages/import/components/index.scss deleted file mode 100644 index 0bccecf..0000000 --- a/src/newsreader/scss/pages/import/components/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "import-form/index"; diff --git a/src/newsreader/scss/pages/import/elements/index.scss b/src/newsreader/scss/pages/import/elements/index.scss deleted file mode 100644 index e69de29..0000000 diff --git a/src/newsreader/scss/pages/import/index.scss b/src/newsreader/scss/pages/import/index.scss index 16b6493..8b13789 100644 --- a/src/newsreader/scss/pages/import/index.scss +++ b/src/newsreader/scss/pages/import/index.scss @@ -1,8 +1 @@ -// General imports -@import "../../partials/variables"; -@import "../../components/index"; -@import "../../elements/index"; -// Page specific -@import "./components/index"; -@import "./elements/index"; diff --git a/src/newsreader/scss/pages/index.scss b/src/newsreader/scss/pages/index.scss new file mode 100644 index 0000000..872ac89 --- /dev/null +++ b/src/newsreader/scss/pages/index.scss @@ -0,0 +1,12 @@ +@import "categories/index"; +@import "category/index"; + +@import "import/index"; +@import "homepage/index"; + +@import "login/index"; +@import "password-reset/index"; +@import "register/index"; + +@import "rules/index"; +@import "rule/index"; diff --git a/src/newsreader/scss/pages/login/components/form/index.scss b/src/newsreader/scss/pages/login/components/form/index.scss deleted file mode 100644 index dc477a7..0000000 --- a/src/newsreader/scss/pages/login/components/form/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "form"; diff --git a/src/newsreader/scss/pages/login/components/index.scss b/src/newsreader/scss/pages/login/components/index.scss deleted file mode 100644 index 246a1a1..0000000 --- a/src/newsreader/scss/pages/login/components/index.scss +++ /dev/null @@ -1,2 +0,0 @@ -@import "./main/index"; -@import "./form/index"; diff --git a/src/newsreader/scss/pages/login/components/main/_main.scss b/src/newsreader/scss/pages/login/components/main/_main.scss deleted file mode 100644 index 1efb986..0000000 --- a/src/newsreader/scss/pages/login/components/main/_main.scss +++ /dev/null @@ -1,8 +0,0 @@ -.main { - @extend .main; - - margin: 5% auto; - width: 50%; - - border-radius: 4px; -} diff --git a/src/newsreader/scss/pages/login/components/main/index.scss b/src/newsreader/scss/pages/login/components/main/index.scss deleted file mode 100644 index bdb4ce0..0000000 --- a/src/newsreader/scss/pages/login/components/main/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "main"; diff --git a/src/newsreader/scss/pages/login/index.scss b/src/newsreader/scss/pages/login/index.scss index 859e33f..69b946e 100644 --- a/src/newsreader/scss/pages/login/index.scss +++ b/src/newsreader/scss/pages/login/index.scss @@ -1,7 +1,6 @@ -// General imports -@import "../../partials/variables"; -@import "../../components/index"; -@import "../../elements/index"; +#login--page { + margin: 5% auto; + width: 50%; -// Page specific -@import "./components/index"; + border-radius: 4px; +} diff --git a/src/newsreader/scss/pages/password-reset/components/index.scss b/src/newsreader/scss/pages/password-reset/components/index.scss deleted file mode 100644 index 4536bb6..0000000 --- a/src/newsreader/scss/pages/password-reset/components/index.scss +++ /dev/null @@ -1,2 +0,0 @@ -@import "password-reset-form/index"; -@import "password-reset-confirm-form/index"; diff --git a/src/newsreader/scss/pages/password-reset/components/password-reset-confirm-form/index.scss b/src/newsreader/scss/pages/password-reset/components/password-reset-confirm-form/index.scss deleted file mode 100644 index 2448efe..0000000 --- a/src/newsreader/scss/pages/password-reset/components/password-reset-confirm-form/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "password-reset-confirm-form"; diff --git a/src/newsreader/scss/pages/password-reset/components/password-reset-form/index.scss b/src/newsreader/scss/pages/password-reset/components/password-reset-form/index.scss deleted file mode 100644 index 1d60faf..0000000 --- a/src/newsreader/scss/pages/password-reset/components/password-reset-form/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "password-reset-form"; diff --git a/src/newsreader/scss/pages/password-reset/elements/index.scss b/src/newsreader/scss/pages/password-reset/elements/index.scss deleted file mode 100644 index e69de29..0000000 diff --git a/src/newsreader/scss/pages/password-reset/index.scss b/src/newsreader/scss/pages/password-reset/index.scss index 16b6493..8b13789 100644 --- a/src/newsreader/scss/pages/password-reset/index.scss +++ b/src/newsreader/scss/pages/password-reset/index.scss @@ -1,8 +1 @@ -// General imports -@import "../../partials/variables"; -@import "../../components/index"; -@import "../../elements/index"; -// Page specific -@import "./components/index"; -@import "./elements/index"; diff --git a/src/newsreader/scss/pages/register/components/activation-form/index.scss b/src/newsreader/scss/pages/register/components/activation-form/index.scss deleted file mode 100644 index 748302f..0000000 --- a/src/newsreader/scss/pages/register/components/activation-form/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "activation-form"; diff --git a/src/newsreader/scss/pages/register/components/index.scss b/src/newsreader/scss/pages/register/components/index.scss deleted file mode 100644 index 3377ff1..0000000 --- a/src/newsreader/scss/pages/register/components/index.scss +++ /dev/null @@ -1,2 +0,0 @@ -@import "register-form/index"; -@import "activation-form/index"; diff --git a/src/newsreader/scss/pages/register/components/register-form/index.scss b/src/newsreader/scss/pages/register/components/register-form/index.scss deleted file mode 100644 index f0d9b70..0000000 --- a/src/newsreader/scss/pages/register/components/register-form/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "register-form"; diff --git a/src/newsreader/scss/pages/register/elements/index.scss b/src/newsreader/scss/pages/register/elements/index.scss deleted file mode 100644 index e69de29..0000000 diff --git a/src/newsreader/scss/pages/register/index.scss b/src/newsreader/scss/pages/register/index.scss index 16b6493..8b13789 100644 --- a/src/newsreader/scss/pages/register/index.scss +++ b/src/newsreader/scss/pages/register/index.scss @@ -1,8 +1 @@ -// General imports -@import "../../partials/variables"; -@import "../../components/index"; -@import "../../elements/index"; -// Page specific -@import "./components/index"; -@import "./elements/index"; diff --git a/src/newsreader/scss/pages/rule/components/index.scss b/src/newsreader/scss/pages/rule/components/index.scss deleted file mode 100644 index de2a031..0000000 --- a/src/newsreader/scss/pages/rule/components/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "rule-form/index"; diff --git a/src/newsreader/scss/pages/rule/components/rule-form/index.scss b/src/newsreader/scss/pages/rule/components/rule-form/index.scss deleted file mode 100644 index 4c7fbee..0000000 --- a/src/newsreader/scss/pages/rule/components/rule-form/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "rule-form"; diff --git a/src/newsreader/scss/pages/rule/elements/index.scss b/src/newsreader/scss/pages/rule/elements/index.scss deleted file mode 100644 index e69de29..0000000 diff --git a/src/newsreader/scss/pages/rule/index.scss b/src/newsreader/scss/pages/rule/index.scss index 16b6493..8b13789 100644 --- a/src/newsreader/scss/pages/rule/index.scss +++ b/src/newsreader/scss/pages/rule/index.scss @@ -1,8 +1 @@ -// General imports -@import "../../partials/variables"; -@import "../../components/index"; -@import "../../elements/index"; -// Page specific -@import "./components/index"; -@import "./elements/index"; diff --git a/src/newsreader/scss/pages/rules/components/card/index.scss b/src/newsreader/scss/pages/rules/components/card/index.scss deleted file mode 100644 index 484e154..0000000 --- a/src/newsreader/scss/pages/rules/components/card/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "card"; diff --git a/src/newsreader/scss/pages/rules/components/index.scss b/src/newsreader/scss/pages/rules/components/index.scss deleted file mode 100644 index 7e96fec..0000000 --- a/src/newsreader/scss/pages/rules/components/index.scss +++ /dev/null @@ -1,3 +0,0 @@ -@import "card/index"; -@import "rules/index"; -@import "rule-modal/index"; diff --git a/src/newsreader/scss/pages/rules/components/rule-modal/_rule-modal.scss b/src/newsreader/scss/pages/rules/components/rule-modal/_rule-modal.scss deleted file mode 100644 index 0dc53bb..0000000 --- a/src/newsreader/scss/pages/rules/components/rule-modal/_rule-modal.scss +++ /dev/null @@ -1,27 +0,0 @@ -.rule-modal { - display: flex; - flex-direction: column; - align-self: center; - - margin: 20px 0; - width: 50%; - - border-radius: 2px; - background-color: $white; - - &__header { - padding: 5px 20px; - } - - &__content { - padding: 10px 30px; - } - - &__footer { - display: flex; - flex-direction: row; - justify-content: space-between; - - padding: 10px; - } -} diff --git a/src/newsreader/scss/pages/rules/components/rule-modal/index.scss b/src/newsreader/scss/pages/rules/components/rule-modal/index.scss deleted file mode 100644 index c19060f..0000000 --- a/src/newsreader/scss/pages/rules/components/rule-modal/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "rule-modal"; diff --git a/src/newsreader/scss/pages/rules/components/rules/_rules.scss b/src/newsreader/scss/pages/rules/components/rules/_rules.scss deleted file mode 100644 index 3fd4c22..0000000 --- a/src/newsreader/scss/pages/rules/components/rules/_rules.scss +++ /dev/null @@ -1,7 +0,0 @@ -.rules { - &__item { - & > * { - margin: 0; - } - } -} diff --git a/src/newsreader/scss/pages/rules/components/rules/index.scss b/src/newsreader/scss/pages/rules/components/rules/index.scss deleted file mode 100644 index e6a0ebf..0000000 --- a/src/newsreader/scss/pages/rules/components/rules/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "rules"; diff --git a/src/newsreader/scss/pages/rules/elements/index.scss b/src/newsreader/scss/pages/rules/elements/index.scss deleted file mode 100644 index e69de29..0000000 diff --git a/src/newsreader/scss/pages/rules/index.scss b/src/newsreader/scss/pages/rules/index.scss index 16b6493..68b92cb 100644 --- a/src/newsreader/scss/pages/rules/index.scss +++ b/src/newsreader/scss/pages/rules/index.scss @@ -1,8 +1,7 @@ -// General imports -@import "../../partials/variables"; -@import "../../components/index"; -@import "../../elements/index"; - -// Page specific -@import "./components/index"; -@import "./elements/index"; +#rules--page { + .list__item { + & .link { + margin: 0; + } + } +} diff --git a/src/newsreader/scss/partials/_variables.scss b/src/newsreader/scss/partials/index.scss similarity index 100% rename from src/newsreader/scss/partials/_variables.scss rename to src/newsreader/scss/partials/index.scss diff --git a/src/newsreader/templates/base.html b/src/newsreader/templates/base.html index 087aca7..746ef54 100644 --- a/src/newsreader/templates/base.html +++ b/src/newsreader/templates/base.html @@ -1,8 +1,12 @@ +{% load static %} + Newreader - {% block head %}{% endblock %} + {% block head %} + + {% endblock %} @@ -24,7 +28,7 @@ {% if messages %}
      {% for message in messages %} - +
    • {{ message }}
    • {% endfor %} diff --git a/src/newsreader/templates/password-reset/password_reset_complete.html b/src/newsreader/templates/password-reset/password_reset_complete.html index 80c2b69..8a47f55 100755 --- a/src/newsreader/templates/password-reset/password_reset_complete.html +++ b/src/newsreader/templates/password-reset/password_reset_complete.html @@ -3,13 +3,8 @@ {% block title %}{% trans "Password reset complete" %}{% endblock %} -{% block head %} - -{% endblock %} - - {% block content %} -
      +

      {% trans "Password reset complete" %}

      diff --git a/src/newsreader/templates/password-reset/password_reset_confirm.html b/src/newsreader/templates/password-reset/password_reset_confirm.html index ff6883f..c438971 100755 --- a/src/newsreader/templates/password-reset/password_reset_confirm.html +++ b/src/newsreader/templates/password-reset/password_reset_confirm.html @@ -9,12 +9,8 @@ {% block title %}{% trans "Confirm password reset" %}{% endblock %} -{% block head %} - -{% endblock %} - {% block content %} -
      +
      {% if validlink %}
      diff --git a/src/newsreader/templates/password-reset/password_reset_done.html b/src/newsreader/templates/password-reset/password_reset_done.html index 11a59ff..dfa141c 100755 --- a/src/newsreader/templates/password-reset/password_reset_done.html +++ b/src/newsreader/templates/password-reset/password_reset_done.html @@ -3,12 +3,8 @@ {% block title %}{% trans "Password reset" %}{% endblock %} -{% block head %} - -{% endblock %} - {% block content %} -
      +

      {% trans "Password reset" %}

      diff --git a/src/newsreader/templates/password-reset/password_reset_form.html b/src/newsreader/templates/password-reset/password_reset_form.html index 2ceb647..cd5fc3e 100755 --- a/src/newsreader/templates/password-reset/password_reset_form.html +++ b/src/newsreader/templates/password-reset/password_reset_form.html @@ -3,12 +3,8 @@ {% block title %}{% trans "Reset password" %}{% endblock %} -{% block head %} - -{% endblock %} - {% block content %} -
      +
      {% csrf_token %}
      diff --git a/src/newsreader/templates/registration/activation_complete.html b/src/newsreader/templates/registration/activation_complete.html index 77561ef..61ea493 100755 --- a/src/newsreader/templates/registration/activation_complete.html +++ b/src/newsreader/templates/registration/activation_complete.html @@ -3,10 +3,6 @@ {% block title %}{% trans "Account Activated" %}{% endblock %} -{% block head %} - -{% endblock %} - {% comment %} **registration/activation_complete.html** @@ -16,7 +12,7 @@ account is now active. {% endcomment %} {% block content %} -
      +

      {% trans "Account activated" %}

      diff --git a/src/newsreader/templates/registration/activation_failure.html b/src/newsreader/templates/registration/activation_failure.html index 88c9053..5cf0f67 100644 --- a/src/newsreader/templates/registration/activation_failure.html +++ b/src/newsreader/templates/registration/activation_failure.html @@ -3,10 +3,6 @@ {% block title %}{% trans "Activation Failure" %}{% endblock %} -{% block head %} - -{% endblock %} - {% comment %} **registration/activate.html** @@ -17,7 +13,7 @@ Used if account activation fails. With the default setup, has the following cont {% endcomment %} {% block content %} -
      +

      {% trans "Activation Failure" %}

      diff --git a/src/newsreader/templates/registration/activation_resend_complete.html b/src/newsreader/templates/registration/activation_resend_complete.html index 0b63c89..dcf1e79 100644 --- a/src/newsreader/templates/registration/activation_resend_complete.html +++ b/src/newsreader/templates/registration/activation_resend_complete.html @@ -3,10 +3,6 @@ {% block title %}{% trans "Account Activation Resent" %}{% endblock %} -{% block head %} - -{% endblock %} - {% comment %} **registration/resend_activation_complete.html** Used after form for resending account activation is submitted. By default has @@ -17,7 +13,7 @@ the following context: {% endcomment %} {% block content %} -
      +

      {% trans "Account activation resent" %}

      diff --git a/src/newsreader/templates/registration/activation_resend_form.html b/src/newsreader/templates/registration/activation_resend_form.html index e819a1f..f721242 100644 --- a/src/newsreader/templates/registration/activation_resend_form.html +++ b/src/newsreader/templates/registration/activation_resend_form.html @@ -3,10 +3,6 @@ {% block title %}{% trans "Resend Activation Email" %}{% endblock %} -{% block head %} - -{% endblock %} - {% comment %} **registration/resend_activation_form.html** Used to show the form users will fill out to resend the activation email. By @@ -20,7 +16,7 @@ default, has the following context: {% endcomment %} {% block content %} -
      +
      {% csrf_token %}
      diff --git a/src/newsreader/templates/registration/registration_closed.html b/src/newsreader/templates/registration/registration_closed.html index 1ac2ad5..6169ebe 100755 --- a/src/newsreader/templates/registration/registration_closed.html +++ b/src/newsreader/templates/registration/registration_closed.html @@ -3,13 +3,8 @@ {% block title %}{% trans "Registration is closed" %}{% endblock %} -{% block head %} - -{% endblock %} - - {% block content %} -
      +

      {% trans "Registration is closed" %}

      diff --git a/src/newsreader/templates/registration/registration_complete.html b/src/newsreader/templates/registration/registration_complete.html index 6e508a2..cc5f868 100755 --- a/src/newsreader/templates/registration/registration_complete.html +++ b/src/newsreader/templates/registration/registration_complete.html @@ -3,10 +3,6 @@ {% block title %}{% trans "Activation email sent" %}{% endblock %} -{% block head %} - -{% endblock %} - {% comment %} **registration/registration_complete.html** @@ -17,7 +13,7 @@ been sent. {% endcomment %} {% block content %} -
      +

      {% trans "Activation email sent" %}

      diff --git a/src/newsreader/templates/registration/registration_form.html b/src/newsreader/templates/registration/registration_form.html index d5e8a13..9b8619c 100644 --- a/src/newsreader/templates/registration/registration_form.html +++ b/src/newsreader/templates/registration/registration_form.html @@ -2,12 +2,8 @@ {% load static %} -{% block head %} - -{% endblock %} - {% block content %} -
      +
      {% csrf_token %}
      From ab0b24b3d20c5e478c3d34db830713bc3262ae6f Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 23 Feb 2020 10:39:55 +0100 Subject: [PATCH 047/422] Remove type annotations --- src/newsreader/accounts/models.py | 15 +++---- src/newsreader/news/collection/base.py | 40 +++++++++---------- src/newsreader/news/collection/endpoints.py | 6 +-- src/newsreader/news/collection/favicon.py | 13 +++--- src/newsreader/news/collection/feed.py | 33 ++++++++------- src/newsreader/news/collection/forms.py | 4 +- .../news/collection/response_handler.py | 8 ++-- src/newsreader/news/collection/utils.py | 8 ++-- src/newsreader/news/collection/views.py | 8 ++-- src/newsreader/news/core/endpoints.py | 3 +- src/newsreader/news/core/forms.py | 2 +- src/newsreader/news/core/pagination.py | 3 +- src/newsreader/news/core/views.py | 8 ++-- 13 files changed, 65 insertions(+), 86 deletions(-) diff --git a/src/newsreader/accounts/models.py b/src/newsreader/accounts/models.py index 1162c94..423b97b 100644 --- a/src/newsreader/accounts/models.py +++ b/src/newsreader/accounts/models.py @@ -1,8 +1,5 @@ import json -from typing import Iterable - -from django.contrib.auth import get_user_model from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import UserManager as DjangoUserManager from django.db import models @@ -12,7 +9,7 @@ from django_celery_beat.models import IntervalSchedule, PeriodicTask class UserManager(DjangoUserManager): - def _create_user(self, email, password, **extra_fields) -> get_user_model: + def _create_user(self, email, password, **extra_fields): """ Create and save a user with the given username, email, and password. """ @@ -24,14 +21,12 @@ class UserManager(DjangoUserManager): user.save(using=self._db) return user - def create_user(self, email: str, password=None, **extra_fields) -> get_user_model: + def create_user(self, email, password=None, **extra_fields): extra_fields.setdefault("is_staff", False) extra_fields.setdefault("is_superuser", False) return self._create_user(email, password, **extra_fields) - def create_superuser( - self, email: str, password: str, **extra_fields - ) -> get_user_model: + def create_superuser(self, email, password, **extra_fields): extra_fields.setdefault("is_staff", True) extra_fields.setdefault("is_superuser", True) @@ -62,7 +57,7 @@ class User(AbstractUser): USERNAME_FIELD = "email" REQUIRED_FIELDS = [] - def save(self, *args, **kwargs) -> None: + def save(self, *args, **kwargs): super().save(*args, **kwargs) if not self.task: @@ -80,6 +75,6 @@ class User(AbstractUser): self.save() - def delete(self, *args, **kwargs) -> Iterable: + def delete(self, *args, **kwargs): self.task.delete() return super().delete(*args, **kwargs) diff --git a/src/newsreader/news/collection/base.py b/src/newsreader/news/collection/base.py index 0524585..ea8e015 100644 --- a/src/newsreader/news/collection/base.py +++ b/src/newsreader/news/collection/base.py @@ -1,5 +1,3 @@ -from typing import ContextManager, Dict, Optional, Tuple - from django.db.models.query import QuerySet from bs4 import BeautifulSoup @@ -10,13 +8,13 @@ from newsreader.news.collection.utils import fetch class Stream: - def __init__(self, rule: CollectionRule) -> None: + def __init__(self, rule): self.rule = rule - def read(self) -> Tuple: + def read(self): raise NotImplementedError - def parse(self, payload: bytes) -> Dict: + def parse(self, payload): raise NotImplementedError class Meta: @@ -26,16 +24,16 @@ class Stream: class Client: stream = Stream - def __init__(self, rules: Optional[CollectionRule] = None) -> None: + def __init__(self, rules=None): self.rules = rules if rules else CollectionRule.objects.all() - def __enter__(self) -> ContextManager: + def __enter__(self): for rule in self.rules: stream = self.stream(rule) yield stream.read() - def __exit__(self, *args, **kwargs) -> None: + def __exit__(self, *args, **kwargs): pass class Meta: @@ -45,20 +43,20 @@ class Client: class Builder: instances = [] - def __init__(self, stream: Tuple) -> None: + def __init__(self, stream): self.stream = stream - def __enter__(self) -> ContextManager: + def __enter__(self): self.create_posts(self.stream) return self - def __exit__(self, *args, **kwargs) -> None: + def __exit__(self, *args, **kwargs): pass - def create_posts(self, stream: Tuple) -> None: + def create_posts(self, stream): pass - def save(self) -> None: + def save(self): pass class Meta: @@ -69,13 +67,11 @@ class Collector: client = None builder = None - def __init__( - self, client: Optional[Client] = None, builder: Optional[Builder] = None - ) -> None: + def __init__(self, client=None, builder=None): self.client = client if client else self.client self.builder = builder if builder else self.builder - def collect(self, rules: Optional[QuerySet] = None) -> None: + def collect(self, rules=None): with self.client(rules=rules) as client: for data, stream in client: with self.builder((data, stream)) as builder: @@ -86,15 +82,15 @@ class Collector: class WebsiteStream(Stream): - def __init__(self, url: str) -> None: + def __init__(self, url): self.url = url - def read(self) -> Tuple: + def read(self): response = fetch(self.url) return (self.parse(response.content), self) - def parse(self, payload: bytes) -> BeautifulSoup: + def parse(self, payload): try: return BeautifulSoup(payload, "lxml") except TypeError: @@ -102,10 +98,10 @@ class WebsiteStream(Stream): class URLBuilder(Builder): - def __enter__(self) -> ContextManager: + def __enter__(self): return self - def build(self) -> Tuple: + def build(self): data, stream = self.stream rule = stream.rule diff --git a/src/newsreader/news/collection/endpoints.py b/src/newsreader/news/collection/endpoints.py index 02ea917..3605d76 100644 --- a/src/newsreader/news/collection/endpoints.py +++ b/src/newsreader/news/collection/endpoints.py @@ -1,5 +1,3 @@ -from django.db.models.query import QuerySet - from rest_framework import status from rest_framework.generics import ( GenericAPIView, @@ -24,7 +22,7 @@ class ListRuleView(ListCreateAPIView): serializer_class = RuleSerializer pagination_class = ResultSetPagination - def get_queryset(self) -> QuerySet: + def get_queryset(self): user = self.request.user return self.queryset.filter(user=user).order_by("-created") @@ -41,7 +39,7 @@ class NestedRuleView(ListAPIView): pagination_class = LargeResultSetPagination filter_backends = [ReadFilter] - def get_queryset(self) -> QuerySet: + def get_queryset(self): lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field # Default permission is IsOwner, therefore there shouldn't have to be diff --git a/src/newsreader/news/collection/favicon.py b/src/newsreader/news/collection/favicon.py index f0a5a6b..3c83f67 100644 --- a/src/newsreader/news/collection/favicon.py +++ b/src/newsreader/news/collection/favicon.py @@ -1,5 +1,4 @@ from concurrent.futures import ThreadPoolExecutor, as_completed -from typing import ContextManager, List, Optional from urllib.parse import urljoin, urlparse from newsreader.news.collection.base import ( @@ -18,7 +17,7 @@ LINK_RELS = ["icon", "shortcut icon", "apple-touch-icon", "apple-touch-icon-prec class FaviconBuilder(Builder): - def build(self) -> None: + def build(self): rule, soup = self.stream url = self.parse(soup, rule.website_url) @@ -27,7 +26,7 @@ class FaviconBuilder(Builder): rule.favicon = url rule.save() - def parse(self, soup, website_url) -> Optional[str]: + def parse(self, soup, website_url): if not soup.head: return @@ -48,7 +47,7 @@ class FaviconBuilder(Builder): return url - def parse_links(self, links: List) -> Optional[str]: + def parse_links(self, links): favicons = set() icons = set() @@ -73,10 +72,10 @@ class FaviconBuilder(Builder): class FaviconClient(Client): stream = WebsiteStream - def __init__(self, streams: List) -> None: + def __init__(self, streams): self.streams = streams - def __enter__(self) -> ContextManager: + def __enter__(self): with ThreadPoolExecutor(max_workers=10) as executor: futures = { executor.submit(stream.read): rule for rule, stream in self.streams @@ -97,7 +96,7 @@ class FaviconCollector(Collector): feed_client, favicon_client = (FeedClient, FaviconClient) url_builder, favicon_builder = (URLBuilder, FaviconBuilder) - def collect(self, rules: Optional[List] = None) -> None: + def collect(self, rules=None): streams = [] with self.feed_client(rules=rules) as client: diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index 5d80256..8a99466 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -1,5 +1,4 @@ from concurrent.futures import ThreadPoolExecutor, as_completed -from typing import ContextManager, Dict, Generator, List, Optional, Tuple from django.db.models.fields import CharField, TextField from django.template.defaultfilters import truncatechars @@ -27,7 +26,7 @@ from newsreader.news.core.models import Post class FeedBuilder(Builder): instances = [] - def __enter__(self) -> ContextManager: + def __enter__(self): _, stream = self.stream self.instances = [] self.existing_posts = { @@ -36,7 +35,7 @@ class FeedBuilder(Builder): return super().__enter__() - def create_posts(self, stream: Tuple) -> None: + def create_posts(self, stream): data, stream = stream entries = [] @@ -51,7 +50,7 @@ class FeedBuilder(Builder): self.instances = [post for post in posts] - def build(self, entries: List, rule: CollectionRule) -> Generator[Post, None, None]: + def build(self, entries, rule): field_mapping = { "id": "remote_identifier", "title": "title", @@ -90,7 +89,7 @@ class FeedBuilder(Builder): yield Post(**data) - def sanitize_fragment(self, fragment: str) -> Optional[str]: + def sanitize_fragment(self, fragment): if not fragment: return "" @@ -117,23 +116,23 @@ class FeedBuilder(Builder): return value - def get_content(self, items: List) -> str: + def get_content(self, items): content = "\n ".join([item.get("value") for item in items]) return self.sanitize_fragment(content) - def save(self) -> None: + def save(self): for post in self.instances: post.save() class FeedStream(Stream): - def read(self) -> Tuple: + def read(self): url = self.rule.url response = fetch(url) return (self.parse(response.content), self) - def parse(self, payload: bytes) -> Dict: + def parse(self, payload): try: return parse(payload) except TypeError as e: @@ -143,7 +142,7 @@ class FeedStream(Stream): class FeedClient(Client): stream = FeedStream - def __enter__(self) -> ContextManager: + def __enter__(self): streams = [self.stream(rule) for rule in self.rules] with ThreadPoolExecutor(max_workers=10) as executor: @@ -175,19 +174,19 @@ class FeedCollector(Collector): class FeedDuplicateHandler: - def __init__(self, rule: CollectionRule) -> None: + def __init__(self, rule): self.queryset = rule.posts.all() - def __enter__(self) -> ContextManager: + def __enter__(self): self.existing_identifiers = self.queryset.filter( remote_identifier__isnull=False ).values_list("remote_identifier", flat=True) return self - def __exit__(self, *args, **kwargs) -> None: + def __exit__(self, *args, **kwargs): pass - def check(self, instances: List) -> Generator[Post, None, None]: + def check(self, instances): for instance in instances: if instance.remote_identifier in self.existing_identifiers: existing_post = self.handle_duplicate(instance) @@ -200,7 +199,7 @@ class FeedDuplicateHandler: yield instance - def in_database(self, post: Post) -> Optional[bool]: + def in_database(self, post): values = { "url": post.url, "title": post.title, @@ -212,7 +211,7 @@ class FeedDuplicateHandler: if self.is_duplicate(existing_post, values): return True - def is_duplicate(self, existing_post: Post, values: Dict) -> bool: + def is_duplicate(self, existing_post, values): for key, value in values.items(): existing_value = getattr(existing_post, key, None) if existing_value != value: @@ -220,7 +219,7 @@ class FeedDuplicateHandler: return True - def handle_duplicate(self, instance: Post) -> Optional[Post]: + def handle_duplicate(self, instance): try: existing_instance = self.queryset.get( remote_identifier=instance.remote_identifier diff --git a/src/newsreader/news/collection/forms.py b/src/newsreader/news/collection/forms.py index 005054f..d0b02be 100644 --- a/src/newsreader/news/collection/forms.py +++ b/src/newsreader/news/collection/forms.py @@ -13,7 +13,7 @@ class CollectionRuleForm(forms.ModelForm): choices=((timezone, timezone) for timezone in pytz.all_timezones), ) - def __init__(self, *args, **kwargs) -> None: + def __init__(self, *args, **kwargs): self.user = kwargs.pop("user") super().__init__(*args, **kwargs) @@ -21,7 +21,7 @@ class CollectionRuleForm(forms.ModelForm): if self.user: self.fields["category"].queryset = Category.objects.filter(user=self.user) - def save(self, commit=True) -> CollectionRule: + def save(self, commit=True): instance = super().save(commit=False) instance.user = self.user diff --git a/src/newsreader/news/collection/response_handler.py b/src/newsreader/news/collection/response_handler.py index 275bc27..3a16376 100644 --- a/src/newsreader/news/collection/response_handler.py +++ b/src/newsreader/news/collection/response_handler.py @@ -1,5 +1,3 @@ -from typing import ContextManager - from requests.exceptions import ConnectionError as RequestConnectionError from newsreader.news.collection.exceptions import ( @@ -22,10 +20,10 @@ class ResponseHandler: exception_mapping = {RequestConnectionError: StreamConnectionError} - def __enter__(self) -> ContextManager: + def __enter__(self): return self - def handle_response(self, response) -> None: + def handle_response(self, response): status_code = response.status_code if status_code in self.status_code_mapping: @@ -40,5 +38,5 @@ class ResponseHandler: message = getattr(exception, "message", str(exception)) raise stream_exception(message=message) from exception - def __exit__(self, *args, **kwargs) -> None: + def __exit__(self, *args, **kwargs): pass diff --git a/src/newsreader/news/collection/utils.py b/src/newsreader/news/collection/utils.py index 83eb708..261dc14 100644 --- a/src/newsreader/news/collection/utils.py +++ b/src/newsreader/news/collection/utils.py @@ -1,6 +1,4 @@ -from datetime import datetime, tzinfo -from time import struct_time -from typing import Tuple +from datetime import datetime from django.utils import timezone @@ -12,7 +10,7 @@ from requests.models import Response from newsreader.news.collection.response_handler import ResponseHandler -def build_publication_date(dt: struct_time, tz: tzinfo) -> Tuple: +def build_publication_date(dt, tz): try: naive_datetime = datetime(*dt[:6]) published_parsed = timezone.make_aware(naive_datetime, timezone=tz) @@ -21,7 +19,7 @@ def build_publication_date(dt: struct_time, tz: tzinfo) -> Tuple: return published_parsed, True -def fetch(url: str) -> Response: +def fetch(url): with ResponseHandler() as response_handler: try: response = requests.get(url) diff --git a/src/newsreader/news/collection/views.py b/src/newsreader/news/collection/views.py index b62542c..8d254e2 100644 --- a/src/newsreader/news/collection/views.py +++ b/src/newsreader/news/collection/views.py @@ -1,5 +1,3 @@ -from typing import Dict, Iterable - from django.contrib import messages from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ @@ -17,7 +15,7 @@ from newsreader.utils.opml import parse_opml class CollectionRuleViewMixin: queryset = CollectionRule.objects.order_by("name") - def get_queryset(self) -> Iterable: + def get_queryset(self): user = self.request.user return self.queryset.filter(user=user) @@ -26,7 +24,7 @@ class CollectionRuleDetailMixin: success_url = reverse_lazy("rules") form_class = CollectionRuleForm - def get_context_data(self, **kwargs) -> Dict: + def get_context_data(self, **kwargs): context_data = super().get_context_data(**kwargs) rules = Category.objects.filter(user=self.request.user).order_by("name") @@ -37,7 +35,7 @@ class CollectionRuleDetailMixin: return context_data - def get_form_kwargs(self) -> Dict: + def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs["user"] = self.request.user return kwargs diff --git a/src/newsreader/news/core/endpoints.py b/src/newsreader/news/core/endpoints.py index e2c55f7..d3b679b 100644 --- a/src/newsreader/news/core/endpoints.py +++ b/src/newsreader/news/core/endpoints.py @@ -1,5 +1,4 @@ from django.db.models import Q -from django.db.models.query import QuerySet from rest_framework import status from rest_framework.generics import ( @@ -63,7 +62,7 @@ class NestedRuleCategoryView(ListAPIView): queryset = Category.objects.prefetch_related("rules").all() serializer_class = RuleSerializer - def get_queryset(self) -> QuerySet: + def get_queryset(self): lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field # Default permission is IsOwner, therefore there shouldn't have to be diff --git a/src/newsreader/news/core/forms.py b/src/newsreader/news/core/forms.py index 8879335..a86e2b2 100644 --- a/src/newsreader/news/core/forms.py +++ b/src/newsreader/news/core/forms.py @@ -27,7 +27,7 @@ class CategoryForm(forms.ModelForm): self.initial["user"] = self.user - def save(self, commit=True) -> Category: + def save(self, commit=True): instance = super().save(commit=False) if commit: diff --git a/src/newsreader/news/core/pagination.py b/src/newsreader/news/core/pagination.py index 8a09234..ff289c3 100644 --- a/src/newsreader/news/core/pagination.py +++ b/src/newsreader/news/core/pagination.py @@ -1,3 +1,4 @@ +from newsreadern.news.collection.serializers import RuleSerializer from rest_framework import serializers from newsreader.news.posts.models import Category, Post @@ -8,7 +9,7 @@ class CategorySerializer(serializers.ModelSerializer): def get_rules(self, instance): rules = instance.rules.order_by("-modified", "-created") - serializer = CollectionRuleSerializer(rules, many=True) + serializer = RuleSerializer(rules, many=True) return serializer.data class Meta: diff --git a/src/newsreader/news/core/views.py b/src/newsreader/news/core/views.py index 4832837..fde3974 100644 --- a/src/newsreader/news/core/views.py +++ b/src/newsreader/news/core/views.py @@ -1,5 +1,3 @@ -from typing import Dict, Iterable - from django.urls import reverse_lazy from django.views.generic.base import TemplateView from django.views.generic.edit import CreateView, UpdateView @@ -14,7 +12,7 @@ class NewsView(TemplateView): template_name = "core/homepage.html" # TODO serialize objects to show filled main page - def get_context_data(self, **kwargs) -> Dict: + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) user = self.request.user @@ -35,7 +33,7 @@ class NewsView(TemplateView): class CategoryViewMixin: queryset = Category.objects.prefetch_related("rules").order_by("name") - def get_queryset(self) -> Iterable: + def get_queryset(self): user = self.request.user return self.queryset.filter(user=user) @@ -44,7 +42,7 @@ class CategoryDetailMixin: success_url = reverse_lazy("categories") form_class = CategoryForm - def get_context_data(self, **kwargs) -> Dict: + def get_context_data(self, **kwargs): context_data = super().get_context_data(**kwargs) rules = CollectionRule.objects.filter(user=self.request.user).order_by("name") From 961535dd605298c88fbeff0a5ce257df77f5ffee Mon Sep 17 00:00:00 2001 From: sonny Date: Thu, 27 Feb 2020 19:47:45 +0100 Subject: [PATCH 048/422] Add axes integration & add cache configuration --- .gitlab-ci.yml | 1 + docker-compose.yml | 8 ++++++ requirements/base.txt | 2 ++ src/newsreader/conf/base.py | 25 ++++++++++--------- src/newsreader/conf/docker.py | 11 ++++++++ src/newsreader/conf/gitlab.py | 13 ++++++++++ .../tests/endpoints/rule/detail/tests.py | 4 +-- .../tests/endpoints/rule/list/tests.py | 4 +-- .../news/collection/tests/test_views.py | 4 +-- .../tests/endpoints/category/detail/tests.py | 4 +-- .../tests/endpoints/category/list/tests.py | 6 ++--- .../core/tests/endpoints/post/detail/tests.py | 2 +- .../core/tests/endpoints/post/list/tests.py | 2 +- src/newsreader/news/core/tests/test_views.py | 2 +- 14 files changed, 62 insertions(+), 26 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3159668..d7c9f02 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -18,6 +18,7 @@ javascript build: python tests: services: - postgres:11 + - memcached:1.5.22 image: python:3.7.4-slim-stretch stage: test variables: diff --git a/docker-compose.yml b/docker-compose.yml index f4ab666..b940476 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,14 @@ services: - .:/app depends_on: - rabbitmq + memcached: + image: memcached:1.5.22 + container_name: memcached + ports: + - "11211:11211" + entrypoint: + - memcached + - -m 64 web: build: . container_name: web diff --git a/requirements/base.txt b/requirements/base.txt index 033f18c..e362370 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -3,6 +3,7 @@ beautifulsoup4==4.7.1 celery==4.3.0 certifi==2019.3.9 chardet==3.0.4 +django-axes==5.2.2 Django==2.2 django-celery-beat==1.5.0 djangorestframework==3.9.4 @@ -11,6 +12,7 @@ django-registration-redux==2.6 lxml==4.4.2 feedparser==5.2.1 idna==2.8 +python-memcached==1.59 pytz==2018.9 requests==2.21.0 sqlparse==0.3.0 diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 632aff3..3914705 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -1,15 +1,3 @@ -""" -Django settings for newsreader project. - -Generated by "django-admin startproject" using Django 2.2. - -For more information on this file, see -https://docs.djangoproject.com/en/2.2/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/2.2/ref/settings/ -""" - import os from pathlib import Path @@ -43,12 +31,18 @@ INSTALLED_APPS = [ "celery", "django_celery_beat", "registration", + "axes", # app modules "newsreader.accounts", "newsreader.news.core", "newsreader.news.collection", ] +AUTHENTICATION_BACKENDS = [ + "axes.backends.AxesBackend", + "django.contrib.auth.backends.ModelBackend", +] + MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", @@ -57,6 +51,7 @@ MIDDLEWARE = [ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "axes.middleware.AxesMiddleware", ] ROOT_URLCONF = "newsreader.urls" @@ -125,6 +120,12 @@ STATICFILES_FINDERS = [ ] # Third party settings +AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler" +AXES_CACHE = "axes" +AXES_FAILURE_LIMIT = 5 +AXES_COOLOFF_TIME = 3 # in hours +AXES_RESET_ON_SUCCESS = True + REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework.authentication.SessionAuthentication", diff --git a/src/newsreader/conf/docker.py b/src/newsreader/conf/docker.py index c616942..fe20d06 100644 --- a/src/newsreader/conf/docker.py +++ b/src/newsreader/conf/docker.py @@ -4,3 +4,14 @@ from .dev import * # Celery # https://docs.celeryproject.org/en/latest/userguide/configuration.html BROKER_URL = "amqp://guest:guest@rabbitmq:5672//" + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.memcached.MemcachedCache", + "LOCATION": "memcached:11211", + }, + "axes": { + "BACKEND": "django.core.cache.backends.memcached.MemcachedCache", + "LOCATION": "memcached:11211", + }, +} diff --git a/src/newsreader/conf/gitlab.py b/src/newsreader/conf/gitlab.py index ddacab9..3108245 100644 --- a/src/newsreader/conf/gitlab.py +++ b/src/newsreader/conf/gitlab.py @@ -4,3 +4,16 @@ from .base import * # noqa DEBUG = True EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + +AXES_ENABLED = False + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.memcached.MemcachedCache", + "LOCATION": "memcached:11211", + }, + "axes": { + "BACKEND": "django.core.cache.backends.memcached.MemcachedCache", + "LOCATION": "memcached:11211", + }, +} diff --git a/src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py b/src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py index 8dc75d0..a489d55 100644 --- a/src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py +++ b/src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py @@ -12,7 +12,7 @@ from newsreader.news.core.tests.factories import CategoryFactory, PostFactory class CollectionRuleDetailViewTestCase(TestCase): def setUp(self): self.user = UserFactory(password="test") - self.client.login(email=self.user.email, password="test") + self.client.force_login(self.user) def test_simple(self): rule = CollectionRuleFactory(user=self.user) @@ -162,7 +162,7 @@ class CollectionRuleDetailViewTestCase(TestCase): class CollectionRuleReadTestCase(TestCase): def setUp(self): self.user = UserFactory(password="test") - self.client.login(email=self.user.email, password="test") + self.client.force_login(self.user) def test_rule_read(self): rule = CollectionRuleFactory(user=self.user) diff --git a/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py b/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py index 4526bdd..a84a093 100644 --- a/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py +++ b/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py @@ -15,7 +15,7 @@ from newsreader.news.core.tests.factories import CategoryFactory, PostFactory class RuleListViewTestCase(TestCase): def setUp(self): self.user = UserFactory(password="test") - self.client.login(email=self.user.email, password="test") + self.client.force_login(self.user) def test_simple(self): CollectionRuleFactory.create_batch(size=3, user=self.user) @@ -148,7 +148,7 @@ class RuleListViewTestCase(TestCase): class NestedRuleListViewTestCase(TestCase): def setUp(self): self.user = UserFactory(password="test") - self.client.login(email=self.user.email, password="test") + self.client.force_login(self.user) def test_simple(self): rule = CollectionRuleFactory.create(user=self.user) diff --git a/src/newsreader/news/collection/tests/test_views.py b/src/newsreader/news/collection/tests/test_views.py index 5a1a59e..1b87ae6 100644 --- a/src/newsreader/news/collection/tests/test_views.py +++ b/src/newsreader/news/collection/tests/test_views.py @@ -16,7 +16,7 @@ from newsreader.news.core.tests.factories import CategoryFactory class CollectionRuleViewTestCase: def setUp(self): self.user = UserFactory(password="test") - self.client.login(email=self.user.email, password="test") + self.client.force_login(self.user) self.category = CategoryFactory(user=self.user) self.form_data = {"name": "", "category": "", "url": "", "timezone": ""} @@ -155,7 +155,7 @@ class CollectionRuleUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): class OPMLImportTestCase(TestCase): def setUp(self): self.user = UserFactory(password="test") - self.client.login(email=self.user.email, password="test") + self.client.force_login(self.user) self.form_data = {"file": "", "skip_existing": False} self.url = reverse("import") diff --git a/src/newsreader/news/core/tests/endpoints/category/detail/tests.py b/src/newsreader/news/core/tests/endpoints/category/detail/tests.py index 787d8a9..ffe9278 100644 --- a/src/newsreader/news/core/tests/endpoints/category/detail/tests.py +++ b/src/newsreader/news/core/tests/endpoints/category/detail/tests.py @@ -11,7 +11,7 @@ from newsreader.news.core.tests.factories import CategoryFactory, PostFactory class CategoryDetailViewTestCase(TestCase): def setUp(self): self.user = UserFactory(password="test") - self.client.login(email=self.user.email, password="test") + self.client.force_login(self.user) def test_simple(self): category = CategoryFactory(user=self.user) @@ -122,7 +122,7 @@ class CategoryDetailViewTestCase(TestCase): class CategoryReadTestCase(TestCase): def setUp(self): self.user = UserFactory(password="test") - self.client.login(email=self.user.email, password="test") + self.client.force_login(self.user) def test_category_read(self): category = CategoryFactory(user=self.user) diff --git a/src/newsreader/news/core/tests/endpoints/category/list/tests.py b/src/newsreader/news/core/tests/endpoints/category/list/tests.py index f58832e..54058fd 100644 --- a/src/newsreader/news/core/tests/endpoints/category/list/tests.py +++ b/src/newsreader/news/core/tests/endpoints/category/list/tests.py @@ -15,7 +15,7 @@ from newsreader.news.core.tests.factories import CategoryFactory, PostFactory class CategoryListViewTestCase(TestCase): def setUp(self): self.user = UserFactory(password="test") - self.client.login(email=self.user.email, password="test") + self.client.force_login(self.user) def test_simple(self): CategoryFactory.create_batch(size=3, user=self.user) @@ -121,7 +121,7 @@ class CategoryListViewTestCase(TestCase): class NestedCategoryListViewTestCase(TestCase): def setUp(self): self.user = UserFactory(password="test") - self.client.login(email=self.user.email, password="test") + self.client.force_login(self.user) def test_simple(self): category = CategoryFactory.create(user=self.user) @@ -280,7 +280,7 @@ class NestedCategoryListViewTestCase(TestCase): class NestedCategoryPostView(TestCase): def setUp(self): self.user = UserFactory(password="test") - self.client.login(email=self.user.email, password="test") + self.client.force_login(self.user) def test_simple(self): category = CategoryFactory.create(user=self.user) diff --git a/src/newsreader/news/core/tests/endpoints/post/detail/tests.py b/src/newsreader/news/core/tests/endpoints/post/detail/tests.py index bc184a3..f012cc2 100644 --- a/src/newsreader/news/core/tests/endpoints/post/detail/tests.py +++ b/src/newsreader/news/core/tests/endpoints/post/detail/tests.py @@ -11,7 +11,7 @@ from newsreader.news.core.tests.factories import CategoryFactory, PostFactory class PostDetailViewTestCase(TestCase): def setUp(self): self.user = UserFactory(password="test") - self.client.login(email=self.user.email, password="test") + self.client.force_login(self.user) def test_simple(self): rule = CollectionRuleFactory( diff --git a/src/newsreader/news/core/tests/endpoints/post/list/tests.py b/src/newsreader/news/core/tests/endpoints/post/list/tests.py index 013decd..ba7aba9 100644 --- a/src/newsreader/news/core/tests/endpoints/post/list/tests.py +++ b/src/newsreader/news/core/tests/endpoints/post/list/tests.py @@ -13,7 +13,7 @@ from newsreader.news.core.tests.factories import CategoryFactory, PostFactory class PostListViewTestCase(TestCase): def setUp(self): self.user = UserFactory(is_staff=True, password="test") - self.client.login(email=self.user.email, password="test") + self.client.force_login(self.user) def test_simple(self): rule = CollectionRuleFactory( diff --git a/src/newsreader/news/core/tests/test_views.py b/src/newsreader/news/core/tests/test_views.py index ad1dc1d..8b62b85 100644 --- a/src/newsreader/news/core/tests/test_views.py +++ b/src/newsreader/news/core/tests/test_views.py @@ -10,7 +10,7 @@ from newsreader.news.core.tests.factories import CategoryFactory class CategoryViewTestCase: def setUp(self): self.user = UserFactory(password="test") - self.client.login(email=self.user.email, password="test") + self.client.force_login(self.user) def test_simple(self): response = self.client.get(self.url) From 61bc7e9b04806920353d478e12530d09791ce2ce Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 1 Mar 2020 20:33:14 +0100 Subject: [PATCH 049/422] Add cache setting to dev settings --- src/newsreader/conf/dev.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/newsreader/conf/dev.py b/src/newsreader/conf/dev.py index 2b0e050..9d77952 100644 --- a/src/newsreader/conf/dev.py +++ b/src/newsreader/conf/dev.py @@ -9,6 +9,17 @@ EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" INSTALLED_APPS += ["debug_toolbar", "django_extensions"] +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.memcached.MemcachedCache", + "LOCATION": "localhost:11211", + }, + "axes": { + "BACKEND": "django.core.cache.backends.memcached.MemcachedCache", + "LOCATION": "localhost:11211", + }, +} + try: from .local import * # noqa except ImportError: From b3bff398d94464ed77f14c6eb7441890031bc417 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 1 Mar 2020 20:40:04 +0100 Subject: [PATCH 050/422] Update db settings --- src/newsreader/conf/base.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 3914705..c90e9e3 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -78,9 +78,9 @@ WSGI_APPLICATION = "newsreader.wsgi.application" # https://docs.djangoproject.com/en/2.2/ref/settings/#databases DATABASES = { "default": { - "ENGINE": "django.db.backends.postgresql_psycopg2", - "HOST": os.environ.get("POSTGRES_HOST"), - "NAME": os.environ.get("POSTGRES_NAME"), + "ENGINE": "django.db.backends.postgresql", + "HOST": os.environ.get("POSTGRES_HOST", ""), + "NAME": os.environ.get("POSTGRES_NAME", "newsreader"), "USER": os.environ.get("POSTGRES_USER"), "PASSWORD": os.environ.get("POSTGRES_PASSWORD"), } @@ -89,7 +89,9 @@ DATABASES = { # Password validation # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ - {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" + }, {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, From a5de001f35a4a562bf723afcebc19040d3d989d3 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 1 Mar 2020 22:17:09 +0100 Subject: [PATCH 051/422] Apply hooks From 21b9e4f0fdcd1d6e4586513f7e62b6d15c3cc273 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 1 Mar 2020 22:25:47 +0100 Subject: [PATCH 052/422] Set black to default line length (88) --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d7c9f02..4ae3f02 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -82,5 +82,5 @@ python linting: - pip install -r requirements/gitlab.txt script: - isort -rc src/ --check-only - - black -l 90 --check src/ + - black -l 88 --check src/ - autoflake -rc src/ From c22fdfe4ce3d4ffcc5201261115ef307a10500d7 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 1 Mar 2020 22:31:03 +0100 Subject: [PATCH 053/422] More formatting ugh --- .../migrations/0003_auto_20190714_1417.py | 3 ++- src/newsreader/accounts/urls.py | 4 +++- src/newsreader/news/collection/favicon.py | 7 ++++++- src/newsreader/news/collection/feed.py | 8 ++++++-- .../collection/migrations/0001_initial.py | 20 +++++++++++++++---- src/newsreader/news/collection/models.py | 4 +++- .../news/collection/tests/factories.py | 4 +++- .../collection/tests/feed/builder/tests.py | 19 +++++++++++++----- .../collection/tests/feed/collector/tests.py | 4 +++- .../news/collection/tests/test_views.py | 8 ++++++-- .../migrations/0002_auto_20190714_1425.py | 4 +++- src/newsreader/news/core/serializers.py | 4 +++- .../tests/endpoints/category/detail/tests.py | 8 ++++++-- src/newsreader/news/core/tests/test_views.py | 20 ++++++++++++++----- src/newsreader/news/core/urls.py | 8 ++++++-- 15 files changed, 95 insertions(+), 30 deletions(-) diff --git a/src/newsreader/accounts/migrations/0003_auto_20190714_1417.py b/src/newsreader/accounts/migrations/0003_auto_20190714_1417.py index 2cbbb0d..3d55f65 100644 --- a/src/newsreader/accounts/migrations/0003_auto_20190714_1417.py +++ b/src/newsreader/accounts/migrations/0003_auto_20190714_1417.py @@ -11,6 +11,7 @@ class Migration(migrations.Migration): operations = [ migrations.AlterModelManagers( - name="user", managers=[("objects", newsreader.accounts.models.UserManager())] + name="user", + managers=[("objects", newsreader.accounts.models.UserManager())], ) ] diff --git a/src/newsreader/accounts/urls.py b/src/newsreader/accounts/urls.py index ac8b2ab..7b869c7 100644 --- a/src/newsreader/accounts/urls.py +++ b/src/newsreader/accounts/urls.py @@ -21,7 +21,9 @@ urlpatterns = [ path("logout/", LogoutView.as_view(), name="logout"), path("register/", RegistrationView.as_view(), name="register"), path( - "register/complete/", RegistrationCompleteView.as_view(), name="register-complete" + "register/complete/", + RegistrationCompleteView.as_view(), + name="register-complete", ), path("register/closed/", RegistrationClosedView.as_view(), name="register-closed"), path( diff --git a/src/newsreader/news/collection/favicon.py b/src/newsreader/news/collection/favicon.py index 3c83f67..44b96bf 100644 --- a/src/newsreader/news/collection/favicon.py +++ b/src/newsreader/news/collection/favicon.py @@ -13,7 +13,12 @@ from newsreader.news.collection.exceptions import StreamException from newsreader.news.collection.feed import FeedClient -LINK_RELS = ["icon", "shortcut icon", "apple-touch-icon", "apple-touch-icon-precomposed"] +LINK_RELS = [ + "icon", + "shortcut icon", + "apple-touch-icon", + "apple-touch-icon-precomposed", +] class FaviconBuilder(Builder): diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index 8a99466..0e8b258 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -10,7 +10,10 @@ import pytz from feedparser import parse from newsreader.news.collection.base import Builder, Client, Collector, Stream -from newsreader.news.collection.constants import WHITELISTED_ATTRIBUTES, WHITELISTED_TAGS +from newsreader.news.collection.constants import ( + WHITELISTED_ATTRIBUTES, + WHITELISTED_TAGS, +) from newsreader.news.collection.exceptions import ( StreamDeniedException, StreamException, @@ -30,7 +33,8 @@ class FeedBuilder(Builder): _, stream = self.stream self.instances = [] self.existing_posts = { - post.remote_identifier: post for post in Post.objects.filter(rule=stream.rule) + post.remote_identifier: post + for post in Post.objects.filter(rule=stream.rule) } return super().__enter__() diff --git a/src/newsreader/news/collection/migrations/0001_initial.py b/src/newsreader/news/collection/migrations/0001_initial.py index 51b9396..59910e5 100644 --- a/src/newsreader/news/collection/migrations/0001_initial.py +++ b/src/newsreader/news/collection/migrations/0001_initial.py @@ -112,15 +112,24 @@ class Migration(migrations.Migration): ), ("America/Argentina/Cordoba", "America/Argentina/Cordoba"), ("America/Argentina/Jujuy", "America/Argentina/Jujuy"), - ("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"), + ( + "America/Argentina/La_Rioja", + "America/Argentina/La_Rioja", + ), ("America/Argentina/Mendoza", "America/Argentina/Mendoza"), ( "America/Argentina/Rio_Gallegos", "America/Argentina/Rio_Gallegos", ), ("America/Argentina/Salta", "America/Argentina/Salta"), - ("America/Argentina/San_Juan", "America/Argentina/San_Juan"), - ("America/Argentina/San_Luis", "America/Argentina/San_Luis"), + ( + "America/Argentina/San_Juan", + "America/Argentina/San_Juan", + ), + ( + "America/Argentina/San_Luis", + "America/Argentina/San_Luis", + ), ("America/Argentina/Tucuman", "America/Argentina/Tucuman"), ("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"), ("America/Aruba", "America/Aruba"), @@ -183,7 +192,10 @@ class Migration(migrations.Migration): ), ("America/Indiana/Knox", "America/Indiana/Knox"), ("America/Indiana/Marengo", "America/Indiana/Marengo"), - ("America/Indiana/Petersburg", "America/Indiana/Petersburg"), + ( + "America/Indiana/Petersburg", + "America/Indiana/Petersburg", + ), ("America/Indiana/Tell_City", "America/Indiana/Tell_City"), ("America/Indiana/Vevay", "America/Indiana/Vevay"), ("America/Indiana/Vincennes", "America/Indiana/Vincennes"), diff --git a/src/newsreader/news/collection/models.py b/src/newsreader/news/collection/models.py index 4432f77..04ea596 100644 --- a/src/newsreader/news/collection/models.py +++ b/src/newsreader/news/collection/models.py @@ -10,7 +10,9 @@ class CollectionRule(TimeStampedModel): name = models.CharField(max_length=100) url = models.URLField(max_length=1024) - website_url = models.URLField(max_length=1024, editable=False, blank=True, null=True) + website_url = models.URLField( + max_length=1024, editable=False, blank=True, null=True + ) favicon = models.URLField(blank=True, null=True) timezone = models.CharField( diff --git a/src/newsreader/news/collection/tests/factories.py b/src/newsreader/news/collection/tests/factories.py index bddcf1b..678e0f4 100644 --- a/src/newsreader/news/collection/tests/factories.py +++ b/src/newsreader/news/collection/tests/factories.py @@ -9,7 +9,9 @@ class CollectionRuleFactory(factory.django.DjangoModelFactory): url = factory.Faker("url") website_url = factory.Faker("url") - category = factory.SubFactory("newsreader.news.core.tests.factories.CategoryFactory") + category = factory.SubFactory( + "newsreader.news.core.tests.factories.CategoryFactory" + ) user = factory.SubFactory(UserFactory) diff --git a/src/newsreader/news/collection/tests/feed/builder/tests.py b/src/newsreader/news/collection/tests/feed/builder/tests.py index 519a047..be13908 100644 --- a/src/newsreader/news/collection/tests/feed/builder/tests.py +++ b/src/newsreader/news/collection/tests/feed/builder/tests.py @@ -37,10 +37,13 @@ class FeedBuilderTestCase(TestCase): self.assertEquals(Post.objects.count(), 1) self.assertEquals( - post.remote_identifier, "https://www.bbc.co.uk/news/world-us-canada-48338168" + post.remote_identifier, + "https://www.bbc.co.uk/news/world-us-canada-48338168", ) - self.assertEquals(post.url, "https://www.bbc.co.uk/news/world-us-canada-48338168") + self.assertEquals( + post.url, "https://www.bbc.co.uk/news/world-us-canada-48338168" + ) self.assertEquals( post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif" @@ -92,7 +95,9 @@ class FeedBuilderTestCase(TestCase): second_post.url, "https://www.bbc.co.uk/news/technology-48334739" ) - self.assertEquals(second_post.title, "Huawei's Android loss: How it affects you") + self.assertEquals( + second_post.title, "Huawei's Android loss: How it affects you" + ) def test_entry_without_remote_identifier(self): builder = FeedBuilder @@ -332,7 +337,9 @@ class FeedBuilderTestCase(TestCase): self.assertEquals(Post.objects.count(), 1) - self.assertFalse("Foreign Minister Mohammad Javad Zarif says the US" in post.body) + self.assertFalse( + "Foreign Minister Mohammad Javad Zarif says the US" in post.body + ) self.assertTrue("Federal Communications Commission" in post.body) def test_content_detail_is_not_prioritized_if_shorter(self): @@ -347,7 +354,9 @@ class FeedBuilderTestCase(TestCase): self.assertEquals(Post.objects.count(), 1) - self.assertTrue("Foreign Minister Mohammad Javad Zarif says the US" in post.body) + self.assertTrue( + "Foreign Minister Mohammad Javad Zarif says the US" in post.body + ) def test_content_detail_is_concatinated(self): builder = FeedBuilder diff --git a/src/newsreader/news/collection/tests/feed/collector/tests.py b/src/newsreader/news/collection/tests/feed/collector/tests.py index 35dd8d8..e9ae98a 100644 --- a/src/newsreader/news/collection/tests/feed/collector/tests.py +++ b/src/newsreader/news/collection/tests/feed/collector/tests.py @@ -238,7 +238,9 @@ class FeedCollectorTestCase(TestCase): first_post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif" ) - self.assertEquals(second_post.title, "Huawei's Android loss: How it affects you") + self.assertEquals( + second_post.title, "Huawei's Android loss: How it affects you" + ) self.assertEquals( third_post.title, "Birmingham head teacher threatened over LGBT lessons" diff --git a/src/newsreader/news/collection/tests/test_views.py b/src/newsreader/news/collection/tests/test_views.py index 1b87ae6..7515b98 100644 --- a/src/newsreader/news/collection/tests/test_views.py +++ b/src/newsreader/news/collection/tests/test_views.py @@ -178,7 +178,9 @@ class OPMLImportTestCase(TestCase): self.assertEquals(len(rules), 4) def test_existing_rules(self): - CollectionRuleFactory(url="http://www.engadget.com/rss-full.xml", user=self.user) + CollectionRuleFactory( + url="http://www.engadget.com/rss-full.xml", user=self.user + ) CollectionRuleFactory(url="https://news.ycombinator.com/rss", user=self.user) CollectionRuleFactory( url="http://feeds.feedburner.com/Techcrunch", user=self.user @@ -200,7 +202,9 @@ class OPMLImportTestCase(TestCase): self.assertEquals(len(rules), 8) def test_skip_existing_rules(self): - CollectionRuleFactory(url="http://www.engadget.com/rss-full.xml", user=self.user) + CollectionRuleFactory( + url="http://www.engadget.com/rss-full.xml", user=self.user + ) CollectionRuleFactory(url="https://news.ycombinator.com/rss", user=self.user) CollectionRuleFactory( url="http://feeds.feedburner.com/Techcrunch", user=self.user diff --git a/src/newsreader/news/core/migrations/0002_auto_20190714_1425.py b/src/newsreader/news/core/migrations/0002_auto_20190714_1425.py index acb2d9d..bbf4c1a 100644 --- a/src/newsreader/news/core/migrations/0002_auto_20190714_1425.py +++ b/src/newsreader/news/core/migrations/0002_auto_20190714_1425.py @@ -15,7 +15,9 @@ class Migration(migrations.Migration): model_name="category", name="user", field=models.ForeignKey( - on_delete="Owner", related_name="categories", to=settings.AUTH_USER_MODEL + on_delete="Owner", + related_name="categories", + to=settings.AUTH_USER_MODEL, ), ), migrations.AlterField( diff --git a/src/newsreader/news/core/serializers.py b/src/newsreader/news/core/serializers.py index e18070f..e6c7a08 100644 --- a/src/newsreader/news/core/serializers.py +++ b/src/newsreader/news/core/serializers.py @@ -5,7 +5,9 @@ from newsreader.news.core.models import Category, Post class PostSerializer(serializers.ModelSerializer): - publicationDate = serializers.DateTimeField(source="publication_date", required=False) + publicationDate = serializers.DateTimeField( + source="publication_date", required=False + ) remoteIdentifier = serializers.CharField(source="remote_identifier", required=False) class Meta: diff --git a/src/newsreader/news/core/tests/endpoints/category/detail/tests.py b/src/newsreader/news/core/tests/endpoints/category/detail/tests.py index ffe9278..05b4e92 100644 --- a/src/newsreader/news/core/tests/endpoints/category/detail/tests.py +++ b/src/newsreader/news/core/tests/endpoints/category/detail/tests.py @@ -33,7 +33,9 @@ class CategoryDetailViewTestCase(TestCase): def test_post(self): category = CategoryFactory(user=self.user) - response = self.client.post(reverse("api:categories-detail", args=[category.pk])) + response = self.client.post( + reverse("api:categories-detail", args=[category.pk]) + ) data = response.json() self.assertEquals(response.status_code, 405) @@ -206,6 +208,8 @@ class CategoryReadTestCase(TestCase): def test_delete(self): category = CategoryFactory(name="Clickbait", user=self.user) - response = self.client.delete(reverse("api:categories-read", args=[category.pk])) + response = self.client.delete( + reverse("api:categories-read", args=[category.pk]) + ) self.assertEquals(response.status_code, 405) diff --git a/src/newsreader/news/core/tests/test_views.py b/src/newsreader/news/core/tests/test_views.py index 8b62b85..12861fb 100644 --- a/src/newsreader/news/core/tests/test_views.py +++ b/src/newsreader/news/core/tests/test_views.py @@ -122,7 +122,9 @@ class CategoryUpdateViewTestCase(CategoryViewTestCase, TestCase): current_rules = CollectionRuleFactory.create_batch(size=3, user=self.user) self.category.rules.set([*current_rules]) - self.assertCountEqual(self.category.rule_ids, [rule.pk for rule in current_rules]) + self.assertCountEqual( + self.category.rule_ids, [rule.pk for rule in current_rules] + ) data = { "name": self.category.name, @@ -142,7 +144,9 @@ class CategoryUpdateViewTestCase(CategoryViewTestCase, TestCase): current_rules = CollectionRuleFactory.create_batch(size=3, user=self.user) self.category.rules.set([*current_rules]) - self.assertCountEqual(self.category.rule_ids, [rule.pk for rule in current_rules]) + self.assertCountEqual( + self.category.rule_ids, [rule.pk for rule in current_rules] + ) data = {"name": "durp", "user": self.user.pk} self.client.post(self.url, data) @@ -187,7 +191,9 @@ class CategoryUpdateViewTestCase(CategoryViewTestCase, TestCase): current_rules = CollectionRuleFactory.create_batch(size=3, user=self.user) self.category.rules.set([*current_rules]) - self.assertCountEqual(self.category.rule_ids, [rule.pk for rule in current_rules]) + self.assertCountEqual( + self.category.rule_ids, [rule.pk for rule in current_rules] + ) data = { "name": self.category.name, @@ -200,10 +206,14 @@ class CategoryUpdateViewTestCase(CategoryViewTestCase, TestCase): self.assertContains(response, "not one of the available choices") self.category.refresh_from_db() - self.assertCountEqual(self.category.rule_ids, [rule.pk for rule in current_rules]) + self.assertCountEqual( + self.category.rule_ids, [rule.pk for rule in current_rules] + ) other_category.refresh_from_db() - self.assertCountEqual(other_category.rule_ids, [rule.pk for rule in other_rules]) + self.assertCountEqual( + other_category.rule_ids, [rule.pk for rule in other_rules] + ) def test_unique_together(self): other_category = CategoryFactory(name="other category", user=self.user) diff --git a/src/newsreader/news/core/urls.py b/src/newsreader/news/core/urls.py index 36fc46c..4b92428 100644 --- a/src/newsreader/news/core/urls.py +++ b/src/newsreader/news/core/urls.py @@ -37,8 +37,12 @@ endpoints = [ path("posts/", ListPostView.as_view(), name="posts-list"), path("posts//", DetailPostView.as_view(), name="posts-detail"), path("categories/", ListCategoryView.as_view(), name="categories-list"), - path("categories//", DetailCategoryView.as_view(), name="categories-detail"), - path("categories//read/", CategoryReadView.as_view(), name="categories-read"), + path( + "categories//", DetailCategoryView.as_view(), name="categories-detail" + ), + path( + "categories//read/", CategoryReadView.as_view(), name="categories-read" + ), path( "categories//rules/", NestedRuleCategoryView.as_view(), From acd9bd30cba407215d05f89351c61234615118c4 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 1 Mar 2020 22:35:48 +0100 Subject: [PATCH 054/422] Update isort --- .isort.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.isort.cfg b/.isort.cfg index 8d6ccf3..0c8e37f 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,6 +1,6 @@ [settings] include_trailing_comma = true -line_length = 90 +line_length = 88 multi_line_output = 3 skip = env/, venv/ default_section = THIRDPARTY From a87d4f387fb0754355d37a34f56c128d6e122be6 Mon Sep 17 00:00:00 2001 From: sonny Date: Mon, 2 Mar 2020 20:14:38 +0100 Subject: [PATCH 055/422] Replace rest_framework_swagger with drf_yasg rest_framework is deprecated see https://github.com/marcgibbons/django-rest-swagger#django-rest-swagger-deprecated-2019-06-04 --- requirements/base.txt | 2 +- src/newsreader/conf/base.py | 2 +- src/newsreader/news/collection/endpoints.py | 3 +- .../tests/endpoints/rule/list/tests.py | 8 ++--- src/newsreader/news/core/endpoints.py | 2 +- src/newsreader/news/core/pagination.py | 32 ------------------- .../tests/endpoints/category/list/tests.py | 4 +-- src/newsreader/urls.py | 17 ++++++---- 8 files changed, 18 insertions(+), 52 deletions(-) delete mode 100644 src/newsreader/news/core/pagination.py diff --git a/requirements/base.txt b/requirements/base.txt index e362370..a266589 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -7,7 +7,7 @@ django-axes==5.2.2 Django==2.2 django-celery-beat==1.5.0 djangorestframework==3.9.4 -django-rest-swagger==2.2.0 +drf-yasg==1.17.1 django-registration-redux==2.6 lxml==4.4.2 feedparser==5.2.1 diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index c90e9e3..41e1669 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -27,7 +27,7 @@ INSTALLED_APPS = [ "django.contrib.staticfiles", # third party apps "rest_framework", - "rest_framework_swagger", + "drf_yasg", "celery", "django_celery_beat", "registration", diff --git a/src/newsreader/news/collection/endpoints.py b/src/newsreader/news/collection/endpoints.py index 3605d76..0a13766 100644 --- a/src/newsreader/news/collection/endpoints.py +++ b/src/newsreader/news/collection/endpoints.py @@ -2,7 +2,6 @@ from rest_framework import status from rest_framework.generics import ( GenericAPIView, ListAPIView, - ListCreateAPIView, RetrieveUpdateDestroyAPIView, get_object_or_404, ) @@ -17,7 +16,7 @@ from newsreader.news.core.models import Post from newsreader.news.core.serializers import PostSerializer -class ListRuleView(ListCreateAPIView): +class ListRuleView(ListAPIView): queryset = CollectionRule.objects.all() serializer_class = RuleSerializer pagination_class = ResultSetPagination diff --git a/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py b/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py index a84a093..32d8a0d 100644 --- a/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py +++ b/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py @@ -94,13 +94,9 @@ class RuleListViewTestCase(TestCase): content_type="application/json", ) data = response.json() - data["category"] - self.assertEquals(response.status_code, 201) - - self.assertEquals(data["name"], "BBC") - self.assertEquals(data["url"], "https://www.bbc.co.uk") - self.assertEquals(data["category"], category.pk) + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "POST" not allowed.') def test_patch(self): response = self.client.patch(reverse("api:rules-list")) diff --git a/src/newsreader/news/core/endpoints.py b/src/newsreader/news/core/endpoints.py index d3b679b..91736a9 100644 --- a/src/newsreader/news/core/endpoints.py +++ b/src/newsreader/news/core/endpoints.py @@ -44,7 +44,7 @@ class DetailPostView(RetrieveUpdateAPIView): permission_classes = (IsAuthenticated, IsPostOwner) -class ListCategoryView(ListCreateAPIView): +class ListCategoryView(ListAPIView): queryset = Category.objects.all() serializer_class = CategorySerializer diff --git a/src/newsreader/news/core/pagination.py b/src/newsreader/news/core/pagination.py deleted file mode 100644 index ff289c3..0000000 --- a/src/newsreader/news/core/pagination.py +++ /dev/null @@ -1,32 +0,0 @@ -from newsreadern.news.collection.serializers import RuleSerializer -from rest_framework import serializers - -from newsreader.news.posts.models import Category, Post - - -class CategorySerializer(serializers.ModelSerializer): - rules = serializers.SerializerMethodField() - - def get_rules(self, instance): - rules = instance.rules.order_by("-modified", "-created") - serializer = RuleSerializer(rules, many=True) - return serializer.data - - class Meta: - model = Category - fields = ("id", "name", "rules") - - -class PostSerializer(serializers.ModelSerializer): - class Meta: - model = Post - fields = ( - "id", - "title", - "body", - "author", - "publication_date", - "url", - "rule", - "remote_identifier", - ) diff --git a/src/newsreader/news/core/tests/endpoints/category/list/tests.py b/src/newsreader/news/core/tests/endpoints/category/list/tests.py index 54058fd..043e805 100644 --- a/src/newsreader/news/core/tests/endpoints/category/list/tests.py +++ b/src/newsreader/news/core/tests/endpoints/category/list/tests.py @@ -74,8 +74,8 @@ class CategoryListViewTestCase(TestCase): ) response_data = response.json() - self.assertEquals(response.status_code, 201) - self.assertEquals(response_data["name"], "Tech") + self.assertEquals(response.status_code, 405) + self.assertEquals(response_data["detail"], 'Method "POST" not allowed.') def test_patch(self): response = self.client.patch(reverse("api:categories-list")) diff --git a/src/newsreader/urls.py b/src/newsreader/urls.py index fb68235..3b01563 100644 --- a/src/newsreader/urls.py +++ b/src/newsreader/urls.py @@ -2,7 +2,8 @@ from django.conf import settings from django.contrib import admin from django.urls import include, path -from rest_framework_swagger.views import get_swagger_view +from drf_yasg import openapi +from drf_yasg.views import get_schema_view from newsreader.accounts.urls import urlpatterns as login_urls from newsreader.news.collection.urls import endpoints as collection_endpoints @@ -11,19 +12,21 @@ from newsreader.news.core.urls import endpoints as core_endpoints from newsreader.news.core.urls import urlpatterns as core_patterns -schema_view = get_swagger_view(title="Newsreader API") -endpoints = [ - path("", schema_view, name="schema-view"), - *collection_endpoints, - *core_endpoints, +apipatterns = [ + path("api/", include(core_endpoints)), + path("api/", include(collection_endpoints)), ] +schema_info = openapi.Info(title="Newsreader API", default_version="v1") +schema_view = get_schema_view(schema_info, patterns=apipatterns) + urlpatterns = [ path("", include(core_patterns)), path("", include(collection_patterns)), + path("", include((apipatterns, "api")), name="api"), path("accounts/", include((login_urls, "accounts")), name="accounts"), path("admin/", admin.site.urls, name="admin"), - path("api/", include((endpoints, "api")), name="api"), + path("api/", schema_view.with_ui("swagger"), name="api"), path("api/auth/", include("rest_framework.urls"), name="rest_framework"), ] From afc3c11775d665077c32f128e3fadae7bafbf0ec Mon Sep 17 00:00:00 2001 From: Sonny Date: Mon, 2 Mar 2020 23:34:57 +0100 Subject: [PATCH 056/422] Set celery logging level --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index b940476..10c3846 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: celery: build: . container_name: celery - command: celery -A newsreader worker --beat --scheduler django --workdir=/app/src/ + command: celery -A newsreader worker -l INFO --beat --scheduler django --workdir=/app/src/ environment: - POSTGRES_HOST=$POSTGRES_HOST - POSTGRES_NAME=$POSTGRES_NAME From 533561ba1eee746f9b0c23071f1a93e1e117ff63 Mon Sep 17 00:00:00 2001 From: sonny Date: Thu, 5 Mar 2020 00:02:40 +0100 Subject: [PATCH 057/422] Fix data errors --- .gitlab-ci.yml | 2 +- .../accounts/tests/test_activation.py | 3 --- .../accounts/tests/test_password_reset.py | 4 --- .../accounts/tests/test_resend_activation.py | 3 --- src/newsreader/accounts/tests/tests.py | 2 +- src/newsreader/accounts/urls.py | 2 +- src/newsreader/conf/dev.py | 2 +- src/newsreader/conf/docker.py | 3 +-- src/newsreader/conf/gitlab.py | 2 +- src/newsreader/news/collection/base.py | 2 -- src/newsreader/news/collection/endpoints.py | 1 - src/newsreader/news/collection/feed.py | 25 +++++++++++++---- .../migrations/0005_auto_20200303_1932.py | 16 +++++++++++ src/newsreader/news/collection/models.py | 2 +- .../tests/endpoints/rule/detail/tests.py | 2 +- .../tests/endpoints/rule/list/tests.py | 2 +- .../collection/tests/feed/client/tests.py | 27 +++++++++++++++++++ .../tests/feed/duplicate_handler/tests.py | 23 +++++++++++++++- .../news/collection/tests/test_views.py | 2 +- src/newsreader/news/collection/utils.py | 1 - src/newsreader/news/core/endpoints.py | 3 +-- src/newsreader/news/core/serializers.py | 1 - .../tests/endpoints/category/detail/tests.py | 2 +- .../tests/endpoints/category/list/tests.py | 2 +- .../core/tests/endpoints/post/detail/tests.py | 2 +- .../core/tests/endpoints/post/list/tests.py | 2 +- src/newsreader/news/core/tests/test_views.py | 2 +- 27 files changed, 101 insertions(+), 39 deletions(-) create mode 100644 src/newsreader/news/collection/migrations/0005_auto_20200303_1932.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4ae3f02..a45b8a7 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -83,4 +83,4 @@ python linting: script: - isort -rc src/ --check-only - black -l 88 --check src/ - - autoflake -rc src/ + - autoflake --check --remove-all-unused-imports --ignore-init-module-imports --recursive src/ diff --git a/src/newsreader/accounts/tests/test_activation.py b/src/newsreader/accounts/tests/test_activation.py index b5a9943..45d0909 100644 --- a/src/newsreader/accounts/tests/test_activation.py +++ b/src/newsreader/accounts/tests/test_activation.py @@ -1,16 +1,13 @@ import datetime from django.conf import settings -from django.core import mail from django.test import TestCase -from django.test.utils import override_settings from django.urls import reverse from django.utils.translation import gettext as _ from registration.models import RegistrationProfile from newsreader.accounts.models import User -from newsreader.accounts.tests.factories import UserFactory class ActivationTestCase(TestCase): diff --git a/src/newsreader/accounts/tests/test_password_reset.py b/src/newsreader/accounts/tests/test_password_reset.py index 1f818c8..c7871d5 100644 --- a/src/newsreader/accounts/tests/test_password_reset.py +++ b/src/newsreader/accounts/tests/test_password_reset.py @@ -1,14 +1,10 @@ from typing import Dict -from django.contrib.auth.tokens import default_token_generator as token_generator from django.core import mail from django.test import TestCase from django.urls import reverse -from django.utils.encoding import force_bytes -from django.utils.http import urlsafe_base64_encode from django.utils.translation import gettext as _ -from newsreader.accounts.models import User from newsreader.accounts.tests.factories import UserFactory diff --git a/src/newsreader/accounts/tests/test_resend_activation.py b/src/newsreader/accounts/tests/test_resend_activation.py index a18df2a..0209f94 100644 --- a/src/newsreader/accounts/tests/test_resend_activation.py +++ b/src/newsreader/accounts/tests/test_resend_activation.py @@ -1,13 +1,10 @@ -from django.conf import settings from django.core import mail from django.test import TransactionTestCase as TestCase -from django.test.utils import override_settings from django.urls import reverse from django.utils.translation import gettext as _ from registration.models import RegistrationProfile -from newsreader.accounts.models import User from newsreader.accounts.tests.factories import RegistrationProfileFactory, UserFactory diff --git a/src/newsreader/accounts/tests/tests.py b/src/newsreader/accounts/tests/tests.py index b87e8fd..e28dbd3 100644 --- a/src/newsreader/accounts/tests/tests.py +++ b/src/newsreader/accounts/tests/tests.py @@ -1,6 +1,6 @@ from django.test import TestCase -from django_celery_beat.models import IntervalSchedule, PeriodicTask +from django_celery_beat.models import PeriodicTask from newsreader.accounts.models import User diff --git a/src/newsreader/accounts/urls.py b/src/newsreader/accounts/urls.py index 7b869c7..8605233 100644 --- a/src/newsreader/accounts/urls.py +++ b/src/newsreader/accounts/urls.py @@ -1,4 +1,4 @@ -from django.urls import include, path +from django.urls import path from newsreader.accounts.views import ( ActivationCompleteView, diff --git a/src/newsreader/conf/dev.py b/src/newsreader/conf/dev.py index 9d77952..16cf03b 100644 --- a/src/newsreader/conf/dev.py +++ b/src/newsreader/conf/dev.py @@ -1,4 +1,4 @@ -from .base import * +from .base import * # isort:skip DEBUG = True diff --git a/src/newsreader/conf/docker.py b/src/newsreader/conf/docker.py index fe20d06..5643937 100644 --- a/src/newsreader/conf/docker.py +++ b/src/newsreader/conf/docker.py @@ -1,5 +1,4 @@ -from .dev import * - +from .dev import * # isort:skip # Celery # https://docs.celeryproject.org/en/latest/userguide/configuration.html diff --git a/src/newsreader/conf/gitlab.py b/src/newsreader/conf/gitlab.py index 3108245..ee11c59 100644 --- a/src/newsreader/conf/gitlab.py +++ b/src/newsreader/conf/gitlab.py @@ -1,4 +1,4 @@ -from .base import * # noqa +from .base import * # isort:skip DEBUG = True diff --git a/src/newsreader/news/collection/base.py b/src/newsreader/news/collection/base.py index ea8e015..519f4f8 100644 --- a/src/newsreader/news/collection/base.py +++ b/src/newsreader/news/collection/base.py @@ -1,5 +1,3 @@ -from django.db.models.query import QuerySet - from bs4 import BeautifulSoup from newsreader.news.collection.exceptions import StreamParseException diff --git a/src/newsreader/news/collection/endpoints.py b/src/newsreader/news/collection/endpoints.py index 0a13766..7f2ede0 100644 --- a/src/newsreader/news/collection/endpoints.py +++ b/src/newsreader/news/collection/endpoints.py @@ -6,7 +6,6 @@ from rest_framework.generics import ( get_object_or_404, ) from rest_framework.response import Response -from rest_framework.serializers import Serializer from newsreader.core.pagination import LargeResultSetPagination, ResultSetPagination from newsreader.news.collection.models import CollectionRule diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index 0e8b258..46a7a3b 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -1,5 +1,8 @@ +import logging + from concurrent.futures import ThreadPoolExecutor, as_completed +from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist from django.db.models.fields import CharField, TextField from django.template.defaultfilters import truncatechars from django.utils import timezone @@ -21,11 +24,13 @@ from newsreader.news.collection.exceptions import ( StreamParseException, StreamTimeOutException, ) -from newsreader.news.collection.models import CollectionRule from newsreader.news.collection.utils import build_publication_date, fetch from newsreader.news.core.models import Post +logger = logging.getLogger(__name__) + + class FeedBuilder(Builder): instances = [] @@ -164,7 +169,8 @@ class FeedClient(Client): yield response_data except StreamException as e: - stream.rule.error = e.message + length = stream.rule._meta.get_field("error").max_length + stream.rule.error = e.message[-length:] stream.rule.succeeded = False yield ({"entries": []}, stream) @@ -195,8 +201,8 @@ class FeedDuplicateHandler: if instance.remote_identifier in self.existing_identifiers: existing_post = self.handle_duplicate(instance) - if existing_post: - yield existing_post + yield existing_post + continue elif not instance.remote_identifier and self.in_database(instance): continue @@ -229,7 +235,16 @@ class FeedDuplicateHandler: remote_identifier=instance.remote_identifier ) except ObjectDoesNotExist: - return + logger.error( + f"Duplicate handler tried retrieving post {instance.remote_identifier} but failed doing so." + ) + return instance + except MultipleObjectsReturned: + existing_instances = self.queryset.filter( + remote_identifier=instance.remote_identifier + ).order_by("-publication_date") + existing_instance = existing_instances.last() + existing_instances.exclude(pk=existing_instance.pk).delete() for field in instance._meta.get_fields(): getattr(existing_instance, field.name, object()) diff --git a/src/newsreader/news/collection/migrations/0005_auto_20200303_1932.py b/src/newsreader/news/collection/migrations/0005_auto_20200303_1932.py new file mode 100644 index 0000000..cdd3e32 --- /dev/null +++ b/src/newsreader/news/collection/migrations/0005_auto_20200303_1932.py @@ -0,0 +1,16 @@ +# Generated by Django 2.2 on 2020-03-03 19:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("collection", "0004_auto_20190714_1422")] + + operations = [ + migrations.AlterField( + model_name="collectionrule", + name="error", + field=models.CharField(blank=True, max_length=1024, null=True), + ) + ] diff --git a/src/newsreader/news/collection/models.py b/src/newsreader/news/collection/models.py index 04ea596..8552ebf 100644 --- a/src/newsreader/news/collection/models.py +++ b/src/newsreader/news/collection/models.py @@ -33,7 +33,7 @@ class CollectionRule(TimeStampedModel): last_suceeded = models.DateTimeField(blank=True, null=True) succeeded = models.BooleanField(default=False) - error = models.CharField(max_length=255, blank=True, null=True) + error = models.CharField(max_length=1024, blank=True, null=True) user = models.ForeignKey("accounts.User", _("Owner"), related_name="rules") diff --git a/src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py b/src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py index a489d55..1c281d9 100644 --- a/src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py +++ b/src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py @@ -1,6 +1,6 @@ import json -from django.test import Client, TestCase +from django.test import TestCase from django.urls import reverse from newsreader.accounts.tests.factories import UserFactory diff --git a/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py b/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py index 32d8a0d..0e2a269 100644 --- a/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py +++ b/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py @@ -2,7 +2,7 @@ import json from datetime import date, datetime, time -from django.test import Client, TestCase +from django.test import TestCase from django.urls import reverse import pytz diff --git a/src/newsreader/news/collection/tests/feed/client/tests.py b/src/newsreader/news/collection/tests/feed/client/tests.py index 9c11cbd..dd3c1e4 100644 --- a/src/newsreader/news/collection/tests/feed/client/tests.py +++ b/src/newsreader/news/collection/tests/feed/client/tests.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock, patch from django.test import TestCase +from django.utils.lorem_ipsum import words from newsreader.news.collection.exceptions import ( StreamDeniedException, @@ -101,3 +102,29 @@ class FeedClientTestCase(TestCase): self.assertEquals(stream.rule.succeeded, False) self.mocked_read.assert_called_once_with() + + def test_client_catches_stream_parse_exception(self): + rule = CollectionRuleFactory.create() + mock_stream = MagicMock(rule=rule) + self.mocked_read.side_effect = StreamParseException("Stream has wrong contents") + + with FeedClient([rule]) as client: + for data, stream in client: + self.assertEquals(data, {"entries": []}) + self.assertEquals(stream.rule.error, "Stream has wrong contents") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called_once_with() + + def test_client_catches_long_exception_text(self): + rule = CollectionRuleFactory.create() + mock_stream = MagicMock(rule=rule) + self.mocked_read.side_effect = StreamParseException(words(1000)) + + with FeedClient([rule]) as client: + for data, stream in client: + self.assertEquals(data, {"entries": []}) + self.assertEquals(len(stream.rule.error), 1024) + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called_once_with() diff --git a/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py index 18b6a99..b794f3e 100644 --- a/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py +++ b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py @@ -33,7 +33,7 @@ class FeedDuplicateHandlerTestCase(TestCase): self.assertTrue(post.title != existing_post.title) def test_duplicate_entries_in_recent_database(self): - PostFactory.create_batch(size=20) + PostFactory.create_batch(size=10) publication_date = timezone.now() @@ -60,3 +60,24 @@ class FeedDuplicateHandlerTestCase(TestCase): posts = list(posts_gen) self.assertEquals(len(posts), 0) + + def test_multiple_existing_entries_with_identifier(self): + timezone.now() + rule = CollectionRuleFactory() + + PostFactory.create_batch( + remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", rule=rule, size=5 + ) + + new_post = PostFactory.build( + remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", + title="This is a new one", + rule=rule, + ) + + with FeedDuplicateHandler(rule) as duplicate_handler: + posts_gen = duplicate_handler.check([new_post]) + posts = list(posts_gen) + + self.assertEquals(len(posts), 1) + self.assertEquals(posts[0].title, new_post.title) diff --git a/src/newsreader/news/collection/tests/test_views.py b/src/newsreader/news/collection/tests/test_views.py index 7515b98..a9b07d0 100644 --- a/src/newsreader/news/collection/tests/test_views.py +++ b/src/newsreader/news/collection/tests/test_views.py @@ -1,7 +1,7 @@ import os from django.conf import settings -from django.test import Client, TestCase +from django.test import TestCase from django.urls import reverse from django.utils.translation import gettext_lazy as _ diff --git a/src/newsreader/news/collection/utils.py b/src/newsreader/news/collection/utils.py index 261dc14..0aa096f 100644 --- a/src/newsreader/news/collection/utils.py +++ b/src/newsreader/news/collection/utils.py @@ -5,7 +5,6 @@ from django.utils import timezone import requests from requests.exceptions import RequestException -from requests.models import Response from newsreader.news.collection.response_handler import ResponseHandler diff --git a/src/newsreader/news/core/endpoints.py b/src/newsreader/news/core/endpoints.py index 91736a9..acc4ab2 100644 --- a/src/newsreader/news/core/endpoints.py +++ b/src/newsreader/news/core/endpoints.py @@ -13,8 +13,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from newsreader.accounts.permissions import IsPostOwner -from newsreader.core.pagination import LargeResultSetPagination, ResultSetPagination -from newsreader.news.collection.models import CollectionRule +from newsreader.core.pagination import LargeResultSetPagination from newsreader.news.collection.serializers import RuleSerializer from newsreader.news.core.filters import ReadFilter from newsreader.news.core.models import Category, Post diff --git a/src/newsreader/news/core/serializers.py b/src/newsreader/news/core/serializers.py index e6c7a08..d4353c9 100644 --- a/src/newsreader/news/core/serializers.py +++ b/src/newsreader/news/core/serializers.py @@ -1,6 +1,5 @@ from rest_framework import serializers -from newsreader.news import collection from newsreader.news.core.models import Category, Post diff --git a/src/newsreader/news/core/tests/endpoints/category/detail/tests.py b/src/newsreader/news/core/tests/endpoints/category/detail/tests.py index 05b4e92..2bd6bcb 100644 --- a/src/newsreader/news/core/tests/endpoints/category/detail/tests.py +++ b/src/newsreader/news/core/tests/endpoints/category/detail/tests.py @@ -1,6 +1,6 @@ import json -from django.test import Client, TestCase +from django.test import TestCase from django.urls import reverse from newsreader.accounts.tests.factories import UserFactory diff --git a/src/newsreader/news/core/tests/endpoints/category/list/tests.py b/src/newsreader/news/core/tests/endpoints/category/list/tests.py index 043e805..d44f204 100644 --- a/src/newsreader/news/core/tests/endpoints/category/list/tests.py +++ b/src/newsreader/news/core/tests/endpoints/category/list/tests.py @@ -2,7 +2,7 @@ import json from datetime import date, datetime, time -from django.test import Client, TestCase +from django.test import TestCase from django.urls import reverse import pytz diff --git a/src/newsreader/news/core/tests/endpoints/post/detail/tests.py b/src/newsreader/news/core/tests/endpoints/post/detail/tests.py index f012cc2..7c8c31e 100644 --- a/src/newsreader/news/core/tests/endpoints/post/detail/tests.py +++ b/src/newsreader/news/core/tests/endpoints/post/detail/tests.py @@ -1,6 +1,6 @@ import json -from django.test import Client, TestCase +from django.test import TestCase from django.urls import reverse from newsreader.accounts.tests.factories import UserFactory diff --git a/src/newsreader/news/core/tests/endpoints/post/list/tests.py b/src/newsreader/news/core/tests/endpoints/post/list/tests.py index ba7aba9..f3639bf 100644 --- a/src/newsreader/news/core/tests/endpoints/post/list/tests.py +++ b/src/newsreader/news/core/tests/endpoints/post/list/tests.py @@ -1,6 +1,6 @@ from datetime import date, datetime, time -from django.test import Client, TestCase +from django.test import TestCase from django.urls import reverse import pytz diff --git a/src/newsreader/news/core/tests/test_views.py b/src/newsreader/news/core/tests/test_views.py index 12861fb..e4bf458 100644 --- a/src/newsreader/news/core/tests/test_views.py +++ b/src/newsreader/news/core/tests/test_views.py @@ -1,4 +1,4 @@ -from django.test import Client, TestCase +from django.test import TestCase from django.urls import reverse from newsreader.accounts.tests.factories import UserFactory From 4da301eb3ebba104b55bfaa452e856ab7566c60e Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 15 Mar 2020 21:09:25 +0100 Subject: [PATCH 058/422] Add production settings --- requirements/production.txt | 4 ++ src/newsreader/conf/base.py | 26 ++++++++----- src/newsreader/conf/dev.py | 27 +++++++------ src/newsreader/conf/docker.py | 3 ++ src/newsreader/conf/gitlab.py | 2 +- src/newsreader/conf/production.py | 39 +++++++++++++++++++ .../news/collection/tests/test_views.py | 2 +- src/newsreader/wsgi.py | 2 +- 8 files changed, 82 insertions(+), 23 deletions(-) create mode 100644 requirements/production.txt create mode 100644 src/newsreader/conf/production.py diff --git a/requirements/production.txt b/requirements/production.txt new file mode 100644 index 0000000..a12fd45 --- /dev/null +++ b/requirements/production.txt @@ -0,0 +1,4 @@ +-r base.txt + +python-dotenv==0.12.0 +gunicorn==20.0.4 diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 41e1669..2147a71 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -3,16 +3,13 @@ import os from pathlib import Path -BASE_DIR = Path(__file__).resolve().parent.parent +BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent +DJANGO_PROJECT_DIR = os.path.join(BASE_DIR, "src", "newsreader") # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = "^!7a2jq5j!exc-55vf$anx9^6ff6=u_ub5=5p1(1x47fix)syh" - # SECURITY WARNING: don"t run with debug turned on in production! -DEBUG = False +DEBUG = True ALLOWED_HOSTS = ["127.0.0.1"] INTERNAL_IPS = ["127.0.0.1"] @@ -59,7 +56,7 @@ ROOT_URLCONF = "newsreader.urls" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [os.path.join(BASE_DIR, "templates")], + "DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")], "APP_DIRS": True, "OPTIONS": { "context_processors": [ @@ -86,6 +83,17 @@ DATABASES = { } } +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.memcached.MemcachedCache", + "LOCATION": "localhost:11211", + }, + "axes": { + "BACKEND": "django.core.cache.backends.memcached.MemcachedCache", + "LOCATION": "localhost:11211", + }, +} + # Password validation # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ @@ -112,8 +120,8 @@ USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/2.2/howto/static-files/ STATIC_URL = "/static/" - -STATICFILES_DIRS = [os.path.join(BASE_DIR, "static")] +STATIC_ROOT = os.path.join(BASE_DIR, "static") +STATICFILES_DIRS = [os.path.join(DJANGO_PROJECT_DIR, "static")] # https://docs.djangoproject.com/en/2.2/ref/settings/#std:setting-STATICFILES_FINDERS STATICFILES_FINDERS = [ diff --git a/src/newsreader/conf/dev.py b/src/newsreader/conf/dev.py index 16cf03b..7f4b5da 100644 --- a/src/newsreader/conf/dev.py +++ b/src/newsreader/conf/dev.py @@ -1,7 +1,7 @@ from .base import * # isort:skip -DEBUG = True +SECRET_KEY = "mv4&5#+)-=abz3^&1r^nk_ca6y54--p(4n4cg%z*g&rb64j%wl" MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] @@ -9,16 +9,21 @@ EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" INSTALLED_APPS += ["debug_toolbar", "django_extensions"] -CACHES = { - "default": { - "BACKEND": "django.core.cache.backends.memcached.MemcachedCache", - "LOCATION": "localhost:11211", - }, - "axes": { - "BACKEND": "django.core.cache.backends.memcached.MemcachedCache", - "LOCATION": "localhost:11211", - }, -} +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ] + }, + } +] try: from .local import * # noqa diff --git a/src/newsreader/conf/docker.py b/src/newsreader/conf/docker.py index 5643937..3584b30 100644 --- a/src/newsreader/conf/docker.py +++ b/src/newsreader/conf/docker.py @@ -1,5 +1,8 @@ from .dev import * # isort:skip + +SECRET_KEY = "=q(ztyo)b6noom#a164g&s9vcj1aawa^g#ing_ir99=_zl4g&$" + # Celery # https://docs.celeryproject.org/en/latest/userguide/configuration.html BROKER_URL = "amqp://guest:guest@rabbitmq:5672//" diff --git a/src/newsreader/conf/gitlab.py b/src/newsreader/conf/gitlab.py index ee11c59..67a20dd 100644 --- a/src/newsreader/conf/gitlab.py +++ b/src/newsreader/conf/gitlab.py @@ -1,7 +1,7 @@ from .base import * # isort:skip -DEBUG = True +SECRET_KEY = "29%lkw+&n%^w4k#@_db2mo%*tc&xzb)x7xuq*(0$eucii%4r0c" EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" diff --git a/src/newsreader/conf/production.py b/src/newsreader/conf/production.py new file mode 100644 index 0000000..3b6be2a --- /dev/null +++ b/src/newsreader/conf/production.py @@ -0,0 +1,39 @@ +import os + +from dotenv import load_dotenv + + +from .base import * # isort:skip + + +load_dotenv() + +DEBUG = False +ALLOWED_HOSTS = ["rss.fudiggity.nl"] + +SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "HOST": os.environ["POSTGRES_HOST"], + "NAME": os.environ["POSTGRES_NAME"], + "USER": os.environ["POSTGRES_USER"], + "PASSWORD": os.environ["POSTGRES_PASSWORD"], + } +} + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ] + }, + } +] diff --git a/src/newsreader/news/collection/tests/test_views.py b/src/newsreader/news/collection/tests/test_views.py index a9b07d0..0acd3ed 100644 --- a/src/newsreader/news/collection/tests/test_views.py +++ b/src/newsreader/news/collection/tests/test_views.py @@ -161,7 +161,7 @@ class OPMLImportTestCase(TestCase): self.url = reverse("import") def _get_file_path(self, name): - file_dir = os.path.join(settings.BASE_DIR, "utils", "tests", "files") + file_dir = os.path.join(settings.DJANGO_PROJECT_DIR, "utils", "tests", "files") return os.path.join(file_dir, name) def test_simple(self): diff --git a/src/newsreader/wsgi.py b/src/newsreader/wsgi.py index ffe4b3e..5872a04 100644 --- a/src/newsreader/wsgi.py +++ b/src/newsreader/wsgi.py @@ -12,6 +12,6 @@ import os from django.core.wsgi import get_wsgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.production") application = get_wsgi_application() From 420481f18a6ee015dc620031a93552e1a94ed446 Mon Sep 17 00:00:00 2001 From: Sonny Date: Fri, 20 Mar 2020 21:58:22 +0100 Subject: [PATCH 059/422] Squashed commit of the following: commit f1db9b9dc1026760a43028e548572db4e639976e Author: Sonny Date: Mon Mar 16 20:47:15 2020 +0100 Add port setting --- src/newsreader/conf/production.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/newsreader/conf/production.py b/src/newsreader/conf/production.py index 3b6be2a..ce39853 100644 --- a/src/newsreader/conf/production.py +++ b/src/newsreader/conf/production.py @@ -17,6 +17,7 @@ DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", "HOST": os.environ["POSTGRES_HOST"], + "PORT": os.environ["POSTGRES_PORT"], "NAME": os.environ["POSTGRES_NAME"], "USER": os.environ["POSTGRES_USER"], "PASSWORD": os.environ["POSTGRES_PASSWORD"], From e4e4e97cfdee3240d8a4160ff912b165603ef939 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 22 Mar 2020 18:34:05 +0100 Subject: [PATCH 060/422] Add a deployment stage --- .gitlab-ci.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a45b8a7..95606bc 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,6 +2,7 @@ stages: - build - test - lint + - deploy javascript build: image: node:12 @@ -84,3 +85,24 @@ python linting: - isort -rc src/ --check-only - black -l 88 --check src/ - autoflake --check --remove-all-unused-imports --ignore-init-module-imports --recursive src/ + +deploy: + stage: deploy + image: debian:buster + environment: + name: production + url: rss.fudiggity.nl + before_script: + - apt-get update && apt-get install -y ansible + - git clone https://gitlab-ci-token:${CI_JOB_TOKEN}@git.fudiggity.nl/sonny/ansible-playbooks.git deployment + - echo $DEPLOY_HOST > deployment/hosts.yml + - echo $DEPLOY_KEY > deployment/deploy_key + script: + - ansible-playbook deployment/playbook.yml \ + --inventory deployment/hosts.yml \ + --limit newsreader \ + --user ansible \ + --private-key deployment/deploy_key + when: manual + only: + - development From 9ecca7a80be09e28a45d62d5dccd27d8b21cc168 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 22 Mar 2020 18:41:00 +0100 Subject: [PATCH 061/422] Install git in deploy stage --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 95606bc..04950f9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -93,7 +93,7 @@ deploy: name: production url: rss.fudiggity.nl before_script: - - apt-get update && apt-get install -y ansible + - apt-get update && apt-get install -y ansible git - git clone https://gitlab-ci-token:${CI_JOB_TOKEN}@git.fudiggity.nl/sonny/ansible-playbooks.git deployment - echo $DEPLOY_HOST > deployment/hosts.yml - echo $DEPLOY_KEY > deployment/deploy_key From fdec1efcf07758a1dfff8656530a23ee66a1cafb Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 22 Mar 2020 18:50:40 +0100 Subject: [PATCH 062/422] Attempt 2 --- .gitlab-ci.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 04950f9..a033978 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -98,11 +98,7 @@ deploy: - echo $DEPLOY_HOST > deployment/hosts.yml - echo $DEPLOY_KEY > deployment/deploy_key script: - - ansible-playbook deployment/playbook.yml \ - --inventory deployment/hosts.yml \ - --limit newsreader \ - --user ansible \ - --private-key deployment/deploy_key + - ansible-playbook deployment/playbook.yml --inventory deployment/hosts.yml --limit newsreader --user ansible --private-key deployment/deploy_key when: manual only: - development From 99ba773b9116c6a0fe170025382c8cc7fe054361 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 22 Mar 2020 19:03:37 +0100 Subject: [PATCH 063/422] Another attempt --- .gitlab-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a033978..1452185 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -95,8 +95,8 @@ deploy: before_script: - apt-get update && apt-get install -y ansible git - git clone https://gitlab-ci-token:${CI_JOB_TOKEN}@git.fudiggity.nl/sonny/ansible-playbooks.git deployment - - echo $DEPLOY_HOST > deployment/hosts.yml - - echo $DEPLOY_KEY > deployment/deploy_key + - echo "$DEPLOY_HOST" > deployment/hosts.yml + - echo "$DEPLOY_KEY" > deployment/deploy_key script: - ansible-playbook deployment/playbook.yml --inventory deployment/hosts.yml --limit newsreader --user ansible --private-key deployment/deploy_key when: manual From f6100416c36c38f8926171434a0184e8b0f02ffc Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 22 Mar 2020 19:17:54 +0100 Subject: [PATCH 064/422] Add (known) ssh host key --- .gitlab-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1452185..0a92755 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -95,6 +95,8 @@ deploy: before_script: - apt-get update && apt-get install -y ansible git - git clone https://gitlab-ci-token:${CI_JOB_TOKEN}@git.fudiggity.nl/sonny/ansible-playbooks.git deployment + - mkdir /root/.ssh + - echo "192.168.178.63 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILbtcdgJBhVCKsO88cV19EYefDTopdYejEQCp1pYr1Ga" > /root/.ssh/known_hosts - echo "$DEPLOY_HOST" > deployment/hosts.yml - echo "$DEPLOY_KEY" > deployment/deploy_key script: From 770ace2f5d939728914bf40bf8dbc46d48d0f3c3 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 22 Mar 2020 19:24:26 +0100 Subject: [PATCH 065/422] Set correct file permissions --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0a92755..a698d74 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -98,7 +98,7 @@ deploy: - mkdir /root/.ssh - echo "192.168.178.63 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILbtcdgJBhVCKsO88cV19EYefDTopdYejEQCp1pYr1Ga" > /root/.ssh/known_hosts - echo "$DEPLOY_HOST" > deployment/hosts.yml - - echo "$DEPLOY_KEY" > deployment/deploy_key + - echo "$DEPLOY_KEY" > deployment/deploy_key && chmod 0600 deployment/deploy_key script: - ansible-playbook deployment/playbook.yml --inventory deployment/hosts.yml --limit newsreader --user ansible --private-key deployment/deploy_key when: manual From b5b59c5baf502a98db414f372057d6289a2a3d04 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 22 Mar 2020 20:18:28 +0100 Subject: [PATCH 066/422] Remove deploy_hosts --- .gitlab-ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a698d74..53600dd 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -97,10 +97,13 @@ deploy: - git clone https://gitlab-ci-token:${CI_JOB_TOKEN}@git.fudiggity.nl/sonny/ansible-playbooks.git deployment - mkdir /root/.ssh - echo "192.168.178.63 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILbtcdgJBhVCKsO88cV19EYefDTopdYejEQCp1pYr1Ga" > /root/.ssh/known_hosts - - echo "$DEPLOY_HOST" > deployment/hosts.yml - echo "$DEPLOY_KEY" > deployment/deploy_key && chmod 0600 deployment/deploy_key script: - - ansible-playbook deployment/playbook.yml --inventory deployment/hosts.yml --limit newsreader --user ansible --private-key deployment/deploy_key + - ansible-playbook deployment/playbook.yml \ + --inventory deployment/apps.yml \ + --limit newsreader \ + --user ansible \ + --private-key deployment/deploy_key when: manual only: - development From 6bfb84dab9c42f61437f39f1da1a0295b90297b0 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 22 Mar 2020 20:27:15 +0100 Subject: [PATCH 067/422] Remove manual tag & multiline statement --- .gitlab-ci.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 53600dd..4c5e818 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -99,11 +99,6 @@ deploy: - echo "192.168.178.63 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILbtcdgJBhVCKsO88cV19EYefDTopdYejEQCp1pYr1Ga" > /root/.ssh/known_hosts - echo "$DEPLOY_KEY" > deployment/deploy_key && chmod 0600 deployment/deploy_key script: - - ansible-playbook deployment/playbook.yml \ - --inventory deployment/apps.yml \ - --limit newsreader \ - --user ansible \ - --private-key deployment/deploy_key - when: manual + - ansible-playbook deployment/playbook.yml --inventory deployment/apps.yml --limit newsreader --user ansible --private-key deployment/deploy_key only: - development From fdb90525a8d9aa9aa3381d0c4d48b2806a5e7c27 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 22 Mar 2020 20:50:51 +0100 Subject: [PATCH 068/422] Set default_from_email --- src/newsreader/conf/base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 2147a71..211e1d5 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -129,6 +129,8 @@ STATICFILES_FINDERS = [ "django.contrib.staticfiles.finders.AppDirectoriesFinder", ] +DEFAULT_FROM_EMAIL = "newsreader@rss.fudiggity.nl" + # Third party settings AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler" AXES_CACHE = "axes" From ab5d9ea46d2a719a0fcdc7188761b5c43dd047fd Mon Sep 17 00:00:00 2001 From: sonny Date: Sun, 22 Mar 2020 22:26:14 +0100 Subject: [PATCH 069/422] Icon refactor --- .gitignore | 3 + package-lock.json | 274 +++++++----------- package.json | 8 +- .../js/pages/homepage/components/PostModal.js | 7 +- .../homepage/components/feedlist/FeedList.js | 6 +- .../homepage/components/feedlist/PostItem.js | 2 +- .../components/sidebar/CategoryItem.js | 6 +- .../post-message/_post-message.scss | 2 +- .../scss/components/post/_post.scss | 14 +- .../components/posts-info/_posts-info.scss | 3 +- src/newsreader/scss/elements/link/_link.scss | 4 + src/newsreader/scss/index.scss | 1 + src/newsreader/scss/lib/_css.gg.scss | 1 + src/newsreader/scss/lib/index.scss | 1 + 14 files changed, 133 insertions(+), 199 deletions(-) create mode 100644 src/newsreader/scss/lib/_css.gg.scss create mode 100644 src/newsreader/scss/lib/index.scss diff --git a/.gitignore b/.gitignore index 4fe2f8d..f29dcf2 100644 --- a/.gitignore +++ b/.gitignore @@ -32,7 +32,10 @@ dist/ downloads/ eggs/ .eggs/ + lib/ +!src/newsreader/scss/lib + lib64/ parts/ sdist/ diff --git a/package-lock.json b/package-lock.json index c2e9c82..af3a478 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1361,9 +1361,9 @@ "dev": true }, "acorn": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.0.tgz", - "integrity": "sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.1.tgz", + "integrity": "sha512-add7dgA5ppRPxCFJoAGfMDi7PIBXq1RtGo7BhbLaxwrXPOmw8gq48Y9ozT01hUKy9byMjlR20EJhu5zlkErEkg==", "dev": true }, "acorn-globals": { @@ -1966,6 +1966,16 @@ "integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==", "dev": true }, + "bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "optional": true, + "requires": { + "file-uri-to-path": "1.0.0" + } + }, "bl": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.2.tgz", @@ -2340,9 +2350,9 @@ } }, "chokidar": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.6.tgz", - "integrity": "sha512-V2jUo67OKkc6ySiRpJrjlpJKl9kDuG+Xb8VgsGzb+aEouhgS1D0weyPU4lEzdAcsCAvrih2J2BqyXqHWvVLw5g==", + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.8.tgz", + "integrity": "sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg==", "dev": true, "requires": { "anymatch": "^2.0.0", @@ -2535,13 +2545,6 @@ "delayed-stream": "~1.0.0" } }, - "commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "optional": true - }, "commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", @@ -3612,6 +3615,13 @@ "whatwg-url": "^6.5.0" } }, + "file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "optional": true + }, "fill-range": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", @@ -3754,14 +3764,15 @@ "dev": true }, "fsevents": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.9.tgz", - "integrity": "sha512-oeyj2H3EjjonWcFjD5NvZNE9Rqe4UW+nQBU2HNeKw0koVLEFIhtyETyAakeAM3de7Z/SW5kcA+fZUait9EApnw==", + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.12.tgz", + "integrity": "sha512-Ggd/Ktt7E7I8pxZRbGIs7vwqAPscSESMrCSkx2FtWeqmheJgCo2R74fTsZFCifr0VTPwqRpPv17+6b8Zp7th0Q==", "dev": true, "optional": true, "requires": { + "bindings": "^1.5.0", "nan": "^2.12.1", - "node-pre-gyp": "^0.12.0" + "node-pre-gyp": "*" }, "dependencies": { "abbrev": { @@ -3809,7 +3820,7 @@ } }, "chownr": { - "version": "1.1.1", + "version": "1.1.4", "bundled": true, "dev": true, "optional": true @@ -3839,7 +3850,7 @@ "optional": true }, "debug": { - "version": "4.1.1", + "version": "3.2.6", "bundled": true, "dev": true, "optional": true, @@ -3866,12 +3877,12 @@ "optional": true }, "fs-minipass": { - "version": "1.2.5", + "version": "1.2.7", "bundled": true, "dev": true, "optional": true, "requires": { - "minipass": "^2.2.1" + "minipass": "^2.6.0" } }, "fs.realpath": { @@ -3897,7 +3908,7 @@ } }, "glob": { - "version": "7.1.3", + "version": "7.1.6", "bundled": true, "dev": true, "optional": true, @@ -3926,7 +3937,7 @@ } }, "ignore-walk": { - "version": "3.0.1", + "version": "3.0.3", "bundled": true, "dev": true, "optional": true, @@ -3945,7 +3956,7 @@ } }, "inherits": { - "version": "2.0.3", + "version": "2.0.4", "bundled": true, "dev": true, "optional": true @@ -3981,13 +3992,13 @@ } }, "minimist": { - "version": "0.0.8", + "version": "1.2.5", "bundled": true, "dev": true, "optional": true }, "minipass": { - "version": "2.3.5", + "version": "2.9.0", "bundled": true, "dev": true, "optional": true, @@ -3997,42 +4008,42 @@ } }, "minizlib": { - "version": "1.2.1", + "version": "1.3.3", "bundled": true, "dev": true, "optional": true, "requires": { - "minipass": "^2.2.1" + "minipass": "^2.9.0" } }, "mkdirp": { - "version": "0.5.1", + "version": "0.5.3", "bundled": true, "dev": true, "optional": true, "requires": { - "minimist": "0.0.8" + "minimist": "^1.2.5" } }, "ms": { - "version": "2.1.1", + "version": "2.1.2", "bundled": true, "dev": true, "optional": true }, "needle": { - "version": "2.3.0", + "version": "2.3.3", "bundled": true, "dev": true, "optional": true, "requires": { - "debug": "^4.1.0", + "debug": "^3.2.6", "iconv-lite": "^0.4.4", "sax": "^1.2.4" } }, "node-pre-gyp": { - "version": "0.12.0", + "version": "0.14.0", "bundled": true, "dev": true, "optional": true, @@ -4046,11 +4057,11 @@ "rc": "^1.2.7", "rimraf": "^2.6.1", "semver": "^5.3.0", - "tar": "^4" + "tar": "^4.4.2" } }, "nopt": { - "version": "4.0.1", + "version": "4.0.3", "bundled": true, "dev": true, "optional": true, @@ -4060,19 +4071,29 @@ } }, "npm-bundled": { - "version": "1.0.6", + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "npm-normalize-package-bin": "^1.0.1" + } + }, + "npm-normalize-package-bin": { + "version": "1.0.1", "bundled": true, "dev": true, "optional": true }, "npm-packlist": { - "version": "1.4.1", + "version": "1.4.8", "bundled": true, "dev": true, "optional": true, "requires": { "ignore-walk": "^3.0.1", - "npm-bundled": "^1.0.1" + "npm-bundled": "^1.0.1", + "npm-normalize-package-bin": "^1.0.1" } }, "npmlog": { @@ -4137,7 +4158,7 @@ "optional": true }, "process-nextick-args": { - "version": "2.0.0", + "version": "2.0.1", "bundled": true, "dev": true, "optional": true @@ -4152,18 +4173,10 @@ "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" - }, - "dependencies": { - "minimist": { - "version": "1.2.0", - "bundled": true, - "dev": true, - "optional": true - } } }, "readable-stream": { - "version": "2.3.6", + "version": "2.3.7", "bundled": true, "dev": true, "optional": true, @@ -4178,7 +4191,7 @@ } }, "rimraf": { - "version": "2.6.3", + "version": "2.7.1", "bundled": true, "dev": true, "optional": true, @@ -4205,7 +4218,7 @@ "optional": true }, "semver": { - "version": "5.7.0", + "version": "5.7.1", "bundled": true, "dev": true, "optional": true @@ -4258,18 +4271,18 @@ "optional": true }, "tar": { - "version": "4.4.8", + "version": "4.4.13", "bundled": true, "dev": true, "optional": true, "requires": { "chownr": "^1.1.1", "fs-minipass": "^1.2.5", - "minipass": "^2.3.4", - "minizlib": "^1.1.1", + "minipass": "^2.8.6", + "minizlib": "^1.2.1", "mkdirp": "^0.5.0", "safe-buffer": "^5.1.2", - "yallist": "^3.0.2" + "yallist": "^3.0.3" } }, "util-deprecate": { @@ -4294,7 +4307,7 @@ "optional": true }, "yallist": { - "version": "3.0.3", + "version": "3.1.1", "bundled": true, "dev": true, "optional": true @@ -4526,13 +4539,13 @@ } }, "globule": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/globule/-/globule-1.3.0.tgz", - "integrity": "sha512-YlD4kdMqRCQHrhVdonet4TdRtv1/sZKepvoxNT4Nrhrp5HI8XFfc8kFlGlBn2myBo80aGp8Eft259mbcUJhgSg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/globule/-/globule-1.3.1.tgz", + "integrity": "sha512-OVyWOHgw29yosRHCHo7NncwR1hW5ew0W/UrvtwvjefVJeQ26q4/8r8FmPsSF1hJ93IgWkyv16pCTz6WblMzm/g==", "dev": true, "requires": { "glob": "~7.1.1", - "lodash": "~4.17.10", + "lodash": "~4.17.12", "minimatch": "~3.0.2" } }, @@ -4660,26 +4673,6 @@ "glogg": "^1.0.0" } }, - "handlebars": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.5.3.tgz", - "integrity": "sha512-3yPecJoJHK/4c6aZhSvxOyG4vJKDshV36VHp0iVCDVh7o9w2vwi3NSnL2MMPj3YdduqaBcu7cGbggJQM0br9xA==", - "dev": true, - "requires": { - "neo-async": "^2.6.0", - "optimist": "^0.6.1", - "source-map": "^0.6.1", - "uglify-js": "^3.1.4" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - } - } - }, "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", @@ -4827,6 +4820,12 @@ "whatwg-encoding": "^1.0.1" } }, + "html-escaper": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.1.tgz", + "integrity": "sha512-hNX23TjWwD3q56HpWjUHOKj1+4KKlnjv9PcmBUYKVpga+2cnb9nDx/B1o0yO4n+RZXZdiNxzx6B24C9aNMTkkQ==", + "dev": true + }, "htmlescape": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/htmlescape/-/htmlescape-1.1.1.tgz", @@ -4887,9 +4886,9 @@ "dev": true }, "in-publish": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/in-publish/-/in-publish-2.0.0.tgz", - "integrity": "sha1-4g/146KvwmkDILbcVSaCqcf631E=", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/in-publish/-/in-publish-2.0.1.tgz", + "integrity": "sha512-oDM0kUSNFC31ShNxHKUyfZKy8ZeXZBWMjMdZHKLOk13uvT27VTL/QzRGfRUcevJhpkZAvlhPYuXkF7eNWrtyxQ==", "dev": true }, "indent-string": { @@ -5090,13 +5089,10 @@ "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=" }, "is-finite": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.2.tgz", - "integrity": "sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=", - "dev": true, - "requires": { - "number-is-nan": "^1.0.0" - } + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz", + "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==", + "dev": true }, "is-fullwidth-code-point": { "version": "2.0.0", @@ -5366,12 +5362,12 @@ } }, "istanbul-reports": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-2.2.6.tgz", - "integrity": "sha512-SKi4rnMyLBKe0Jy2uUdx28h8oG7ph2PPuQPvIAh31d+Ci+lSiEu4C+h3oBPuJ9+mPKhOyW0M8gY4U5NM1WLeXA==", + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-2.2.7.tgz", + "integrity": "sha512-uu1F/L1o5Y6LzPVSVZXNOoD/KXpJue9aeLRd0sM9uMXfZvzomB0WxVamWb5ue8kA2vVWEmW7EG+A5n3f1kqHKg==", "dev": true, "requires": { - "handlebars": "^4.1.2" + "html-escaper": "^2.0.0" } }, "jest": { @@ -6057,9 +6053,9 @@ } }, "js-base64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.5.1.tgz", - "integrity": "sha512-M7kLczedRMYX4L8Mdh4MzyAMM9O5osx+4FcOQuTvr3A9F2D9S5JXheN0ewNbrvK2UatkTRhL5ejGmGSjNMiZuw==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.5.2.tgz", + "integrity": "sha512-Vg8czh0Q7sFBSUMWWArX/miJeBWYBPpdU/3M/DKSaekLMqrqVPaedp+5mZhie/r0lgrcaYBfwXatEew6gwgiQQ==", "dev": true }, "js-cookie": { @@ -6211,9 +6207,9 @@ "dev": true }, "kind-of": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", - "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", "dev": true }, "kleur": { @@ -6593,9 +6589,9 @@ } }, "minimist": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", - "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", "dev": true }, "mixin-deep": { @@ -6620,20 +6616,12 @@ } }, "mkdirp": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", - "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.3.tgz", + "integrity": "sha512-P+2gwrFqx8lhew375MQHHeTlY8AuOJSrGf0R5ddkEndUkmwpgUob/vQuBD1V22/Cw1/lJr4x+EjllSezBThzBg==", "dev": true, "requires": { - "minimist": "0.0.8" - }, - "dependencies": { - "minimist": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", - "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", - "dev": true - } + "minimist": "^1.2.5" } }, "module-deps": { @@ -6702,12 +6690,6 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, - "neo-async": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.1.tgz", - "integrity": "sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw==", - "dev": true - }, "next-tick": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", @@ -6806,9 +6788,9 @@ } }, "node-sass": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/node-sass/-/node-sass-4.13.0.tgz", - "integrity": "sha512-W1XBrvoJ1dy7VsvTAS5q1V45lREbTlZQqFbiHb3R3OTTCma0XBtuG6xZ6Z4506nR4lmHPTqVRwxT6KgtWC97CA==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/node-sass/-/node-sass-4.13.1.tgz", + "integrity": "sha512-TTWFx+ZhyDx1Biiez2nB0L3YrCZ/8oHagaDalbuBSlqXgUPsdkUSzJsVxeDO9LtPB49+Fh3WQl3slABo6AotNw==", "dev": true, "requires": { "async-foreach": "^0.1.3", @@ -7064,24 +7046,6 @@ "wrappy": "1" } }, - "optimist": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", - "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", - "dev": true, - "requires": { - "minimist": "~0.0.1", - "wordwrap": "~0.0.2" - }, - "dependencies": { - "minimist": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", - "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=", - "dev": true - } - } - }, "optionator": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", @@ -9080,26 +9044,6 @@ "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", "dev": true }, - "uglify-js": { - "version": "3.7.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.7.3.tgz", - "integrity": "sha512-7tINm46/3puUA4hCkKYo4Xdts+JDaVC9ZPRcG8Xw9R4nhO/gZgUM3TENq8IF4Vatk8qCig4MzP/c8G4u2BkVQg==", - "dev": true, - "optional": true, - "requires": { - "commander": "~2.20.3", - "source-map": "~0.6.1" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "optional": true - } - } - }, "umd": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/umd/-/umd-3.0.3.tgz", @@ -9535,12 +9479,6 @@ "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", "dev": true }, - "wordwrap": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", - "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=", - "dev": true - }, "wrap-ansi": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", diff --git a/package.json b/package.json index c883b2c..48d1206 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,10 @@ "description": "Application for viewing RSS feeds", "main": "index.js", "scripts": { - "lint": "prettier \"src/newsreader/js/**/*.js\" --check", - "format": "prettier \"src/newsreader/js/**/*.js\" --write", + "lint": "npx prettier \"src/newsreader/js/**/*.js\" --check", + "format": "npx prettier \"src/newsreader/js/**/*.js\" --write", "watch": "npx gulp watch", - "test": "jest", + "test": "npx jest", "test:watch": "npm test -- --watch" }, "repository": { @@ -49,7 +49,7 @@ "gulp-sass": "^4.0.2", "jest": "^24.9.0", "node-fetch": "^2.6.0", - "node-sass": "^4.13.0", + "node-sass": "^4.13.1", "prettier": "^1.19.1", "react": "^16.12.0", "react-dom": "^16.12.0", diff --git a/src/newsreader/js/pages/homepage/components/PostModal.js b/src/newsreader/js/pages/homepage/components/PostModal.js index 5d5cb71..c540982 100644 --- a/src/newsreader/js/pages/homepage/components/PostModal.js +++ b/src/newsreader/js/pages/homepage/components/PostModal.js @@ -51,10 +51,7 @@ class PostModal extends React.Component { className="button post__close-button" onClick={() => this.props.unSelectPost()} > - Close{' '} - - - + Close

      {`${post.title} `}

      @@ -66,7 +63,7 @@ class PostModal extends React.Component { target="_blank" rel="noopener noreferrer" > - +
      diff --git a/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js b/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js index 7b0e91d..2abcc63 100644 --- a/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js +++ b/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js @@ -52,10 +52,8 @@ class FeedList extends React.Component { return (
      - -

      - Select a category or rule to show its unread posts -

      + +

      Select an item to show its unread posts

      ); diff --git a/src/newsreader/js/pages/homepage/components/feedlist/PostItem.js b/src/newsreader/js/pages/homepage/components/feedlist/PostItem.js index 464e613..a24b9c0 100644 --- a/src/newsreader/js/pages/homepage/components/feedlist/PostItem.js +++ b/src/newsreader/js/pages/homepage/components/feedlist/PostItem.js @@ -34,7 +34,7 @@ class PostItem extends React.Component { target="_blank" rel="noopener noreferrer" > - +
      diff --git a/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js b/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js index 96ded55..d1a0c94 100644 --- a/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js +++ b/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js @@ -24,9 +24,7 @@ class CategoryItem extends React.Component { } render() { - const imageSrc = this.state.open - ? '/static/icons/chevron-down.svg' - : '/static/icons/chevron-right.svg'; + const chevronClass = this.state.open ? 'gg-chevron-down' : 'gg-chevron-right'; const selected = isSelected(this.props.category, this.props.selected, CATEGORY_TYPE); const className = selected ? 'category category--selected' : 'category'; @@ -38,7 +36,7 @@ class CategoryItem extends React.Component {
    • this.toggleRules()}> - +
      this.handleSelect()}> diff --git a/src/newsreader/scss/components/post-message/_post-message.scss b/src/newsreader/scss/components/post-message/_post-message.scss index 21c5603..03a1dc2 100644 --- a/src/newsreader/scss/components/post-message/_post-message.scss +++ b/src/newsreader/scss/components/post-message/_post-message.scss @@ -5,7 +5,7 @@ align-items: center; width: 60%; - height: max-content; + height: 80vh; border-radius: 2px; font-family: $article-font; diff --git a/src/newsreader/scss/components/post/_post.scss b/src/newsreader/scss/components/post/_post.scss index bcf92a6..a7b5ed5 100644 --- a/src/newsreader/scss/components/post/_post.scss +++ b/src/newsreader/scss/components/post/_post.scss @@ -36,7 +36,7 @@ &__link { display: inline-flex; - padding: 0 10px; + padding: 0 15px; & img { width: 30px; @@ -79,19 +79,10 @@ } &__close-button { + position: relative; margin: 1% 2% 0 0; align-self: flex-end; - & span { - display: inline-flex; - align-items: center; - margin: 0 0 0 5px; - - & img { - width: 20px; - } - } - &:hover { background-color: lighten($gainsboro, +1%); } @@ -100,6 +91,7 @@ &__meta-info { display: flex; flex-direction: row; + align-items: center; margin: 15px 0; } diff --git a/src/newsreader/scss/components/posts-info/_posts-info.scss b/src/newsreader/scss/components/posts-info/_posts-info.scss index 7141576..9f9bc6c 100644 --- a/src/newsreader/scss/components/posts-info/_posts-info.scss +++ b/src/newsreader/scss/components/posts-info/_posts-info.scss @@ -1,6 +1,7 @@ .posts-info { display: flex; - justify-content: space-between; + justify-content: space-around; + align-items: center; width: 20%; diff --git a/src/newsreader/scss/elements/link/_link.scss b/src/newsreader/scss/elements/link/_link.scss index b485cb3..1843c0b 100644 --- a/src/newsreader/scss/elements/link/_link.scss +++ b/src/newsreader/scss/elements/link/_link.scss @@ -10,3 +10,7 @@ a { @extend .link; } + +.gg-link { + color: initial; +} diff --git a/src/newsreader/scss/index.scss b/src/newsreader/scss/index.scss index 1fa6f17..2dbf46a 100644 --- a/src/newsreader/scss/index.scss +++ b/src/newsreader/scss/index.scss @@ -1,3 +1,4 @@ +@import "lib/index"; @import "partials/index"; @import "components/index"; @import "elements/index"; diff --git a/src/newsreader/scss/lib/_css.gg.scss b/src/newsreader/scss/lib/_css.gg.scss new file mode 100644 index 0000000..fbdbaa7 --- /dev/null +++ b/src/newsreader/scss/lib/_css.gg.scss @@ -0,0 +1 @@ +@import url("https://css.gg/c"); diff --git a/src/newsreader/scss/lib/index.scss b/src/newsreader/scss/lib/index.scss new file mode 100644 index 0000000..2aca0df --- /dev/null +++ b/src/newsreader/scss/lib/index.scss @@ -0,0 +1 @@ +@import "css.gg"; From a3a2033e37ff91b4739c4b33cd5ba7d435669768 Mon Sep 17 00:00:00 2001 From: sonny Date: Tue, 24 Mar 2020 19:40:33 +0100 Subject: [PATCH 070/422] Resolve "Celery task deduplication" --- .gitlab-ci.yml | 6 +++--- requirements/dev.txt | 4 ++-- src/newsreader/news/collection/tasks.py | 13 ++++++++----- src/newsreader/utils/celery.py | 22 ++++++++++++++++++++++ 4 files changed, 35 insertions(+), 10 deletions(-) create mode 100644 src/newsreader/utils/celery.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4c5e818..a2bc375 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -82,9 +82,9 @@ python linting: - source env/bin/activate - pip install -r requirements/gitlab.txt script: - - isort -rc src/ --check-only - - black -l 88 --check src/ - - autoflake --check --remove-all-unused-imports --ignore-init-module-imports --recursive src/ + - isort src/ --check-only --recursive + - black src/ --line-length 88 --check + - autoflake src/ --check --recursive --remove-all-unused-imports --ignore-init-module-imports deploy: stage: deploy diff --git a/requirements/dev.txt b/requirements/dev.txt index 5bdd7bb..d32f624 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -6,5 +6,5 @@ django-debug-toolbar==2.0 django-extensions==2.1.9 black==19.3b0 -isort==4.3.20 -autoflake==1.3 +isort==4.3.21 +autoflake==1.3.1 diff --git a/src/newsreader/news/collection/tasks.py b/src/newsreader/news/collection/tasks.py index 400b399..b2dbf58 100644 --- a/src/newsreader/news/collection/tasks.py +++ b/src/newsreader/news/collection/tasks.py @@ -3,16 +3,19 @@ from django.core.exceptions import ObjectDoesNotExist from newsreader.accounts.models import User from newsreader.celery import app from newsreader.news.collection.feed import FeedCollector +from newsreader.utils.celery import MemCacheLock -@app.task -def collect(user_pk): +@app.task(bind=True) +def collect(self, user_pk): try: user = User.objects.get(pk=user_pk) except ObjectDoesNotExist: return - rules = user.rules.all() + with MemCacheLock(f"{user.email}-task", self.app.oid) as acquired: + if acquired: + rules = user.rules.all() - collector = FeedCollector() - collector.collect(rules=rules) + collector = FeedCollector() + collector.collect(rules=rules) diff --git a/src/newsreader/utils/celery.py b/src/newsreader/utils/celery.py new file mode 100644 index 0000000..84572c6 --- /dev/null +++ b/src/newsreader/utils/celery.py @@ -0,0 +1,22 @@ +from django.core.cache import cache + +from celery.five import monotonic + + +LOCK_EXPIRE = 60 * 10 # 10 minutes + + +class MemCacheLock: + def __init__(self, lock_id, oid): + self.lock_id = lock_id + self.oid = oid + + self.timeout_at = monotonic() + LOCK_EXPIRE - 3 + + def __enter__(self): + self.status = cache.add(self.lock_id, self.oid, LOCK_EXPIRE) + return self.status + + def __exit__(self, *args, **kwargs): + if monotonic() < self.timeout_at and self.status: + cache.delete(self.lock_id) From b28d42b97bace9101f2223732fe0cd3ce8fbb11e Mon Sep 17 00:00:00 2001 From: sonny Date: Wed, 25 Mar 2020 22:24:32 +0100 Subject: [PATCH 071/422] Resolve "Replace Gulp with Webpack" --- .gitlab-ci.yml | 2 +- .pre-commit-config.yaml | 39 - gulpfile.babel.js | 70 - package-lock.json | 3297 ++++++++--------- package.json | 24 +- src/newsreader/js/pages/categories/index.js | 11 +- .../js/pages/homepage/components/PostModal.js | 2 +- .../homepage/components/feedlist/FeedList.js | 2 +- src/newsreader/js/pages/homepage/index.js | 19 +- src/newsreader/js/pages/rules/index.js | 11 +- .../templates/collection/rules.html | 3 +- .../news/core/templates/core/categories.html | 3 +- .../news/core/templates/core/homepage.html | 4 - src/newsreader/static/icons/rss.png | Bin 0 -> 1913 bytes src/newsreader/templates/base.html | 5 +- webpack.common.babel.js | 33 + webpack.dev.babel.js | 7 + webpack.prod.babel.js | 4 + 18 files changed, 1673 insertions(+), 1863 deletions(-) delete mode 100644 .pre-commit-config.yaml delete mode 100644 gulpfile.babel.js create mode 100644 src/newsreader/static/icons/rss.png create mode 100644 webpack.common.babel.js create mode 100644 webpack.dev.babel.js create mode 100644 webpack.prod.babel.js diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a2bc375..134d7bb 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -14,7 +14,7 @@ javascript build: before_script: - npm install script: - - npx gulp + - npm run build python tests: services: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index e5b4349..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,39 +0,0 @@ -# See https://pre-commit.com for more information -# See https://pre-commit.com/hooks.html for more hooks -default_language_version: - python: python3.7 - -repos: - - repo: local - hooks: - - id: autoflake - name: autoflake - entry: autoflake - language: system - types: [python] - args: ["--in-place", "--remove-unused-variables"] - - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.0.0 - hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-yaml - - id: check-added-large-files - - id: pretty-format-json - - - repo: https://github.com/psf/black - rev: 19.3b0 - hooks: - - id: black - args: [--line-length=90] - - - repo: https://github.com/timothycrosley/isort - rev: 4.3.21-2 - hooks: - - id: isort - - - repo: https://github.com/prettier/prettier - rev: 1.18.2 - hooks: - - id: prettier diff --git a/gulpfile.babel.js b/gulpfile.babel.js deleted file mode 100644 index 8168ac4..0000000 --- a/gulpfile.babel.js +++ /dev/null @@ -1,70 +0,0 @@ -import path from 'path'; -import del from 'del'; - -import { dest, parallel, series, src, watch as _watch } from 'gulp'; -import concat from 'gulp-concat'; -import globby from 'globby'; -import sass from 'gulp-sass'; -import babelify from 'babelify'; -import browserify from 'browserify'; -import source from 'vinyl-source-stream'; -import buffer from 'vinyl-buffer'; - -const PROJECT_DIR = path.join('src', 'newsreader'); -const STATIC_DIR = path.join(PROJECT_DIR, 'static'); -const CSS_SUFFIX = 'css/'; -const JS_SUFFIX = 'js/'; - -const SASS_DEST_DIR = path.join(STATIC_DIR, CSS_SUFFIX); -const JS_DEST_DIR = path.join(STATIC_DIR, JS_SUFFIX); - -const SASS_DIR = path.join(PROJECT_DIR, 'scss'); -const JS_DIR = path.join(PROJECT_DIR, 'js'); - -const clean = () => { - return del([`${STATIC_DIR}/${CSS_SUFFIX}/*.css`, `${STATIC_DIR}/${JS_SUFFIX}/*.js`]); -}; - -const sassTask = () => { - return src(`${SASS_DIR}/index.scss`) - .pipe(sass().on('error', sass.logError)) - .pipe(concat(`main.css`)) - .pipe(dest(SASS_DEST_DIR)); -}; - -const babelTask = () => { - const getDirName = filename => { - const fragments = filename.split('/'); - - return fragments[fragments.length - 2]; - }; - - const promise = globby([`${JS_DIR}/pages/**/index.js`]).then(entries => { - entries.forEach(entry => { - const bundle = browserify({ entries: entry, debug: true }); - const transpiledBundle = bundle.transform(babelify); - const bundleName = getDirName(entry); - - return transpiledBundle - .bundle() - .pipe(source('index.js')) - .pipe(buffer()) - .pipe(concat(`${bundleName}.js`)) - .pipe(dest(JS_DEST_DIR)); - }); - }); - - return Promise.resolve(promise); -}; - -export const watch = () => { - return _watch( - [`${PROJECT_DIR}/scss/**/*.scss`, `${PROJECT_DIR}/js/**/*.js`], - { ignoreInitial: false }, - done => { - series(clean, parallel(sassTask, babelTask))(done); - } - ); -}; - -export default series(clean, parallel(babelTask, sassTask)); diff --git a/package-lock.json b/package-lock.json index af3a478..37f67e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1199,28 +1199,11 @@ "@types/yargs": "^13.0.0" } }, - "@nodelib/fs.scandir": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz", - "integrity": "sha512-eGmwYQn3gxo4r7jdQnkrrN6bY478C3P+a/y72IJukF8LjB6ZHeB3c+Ehacj3sYeSmUXGlnA67/PmbM9CVwL7Dw==", - "requires": { - "@nodelib/fs.stat": "2.0.3", - "run-parallel": "^1.1.9" - } - }, - "@nodelib/fs.stat": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.3.tgz", - "integrity": "sha512-bQBFruR2TAwoevBEd/NWMoAAtNGzTRgdrqnYCc7dhzfoNvqPzLyqlEQnzZ3kVnNrSp25iyxE00/3h2fqGAGArA==" - }, - "@nodelib/fs.walk": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.4.tgz", - "integrity": "sha512-1V9XOY4rDW0rehzbrcqAmHnz8e7SKvX27gh8Gt2WgB0+pdzdiLV83p72kZPU+jvMbS1qU5mauP2iOvO8rhmurQ==", - "requires": { - "@nodelib/fs.scandir": "2.1.3", - "fastq": "^1.6.0" - } + "@types/anymatch": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-1.3.1.tgz", + "integrity": "sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA==", + "dev": true }, "@types/babel__core": { "version": "7.1.3", @@ -1317,12 +1300,82 @@ "integrity": "sha512-GnZbirvmqZUzMgkFn70c74OQpTTUcCzlhQliTzYjQMqg+hVKcDnxdL19Ne3UdYzdMA/+W3eb646FWn/ZaT1NfQ==", "dev": true }, + "@types/source-list-map": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz", + "integrity": "sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==", + "dev": true + }, "@types/stack-utils": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz", "integrity": "sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==", "dev": true }, + "@types/tapable": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.5.tgz", + "integrity": "sha512-/gG2M/Imw7cQFp8PGvz/SwocNrmKFjFsm5Pb8HdbHkZ1K8pmuPzOX4VeVoiEecFCVf4CsN1r3/BRvx+6sNqwtQ==", + "dev": true + }, + "@types/uglify-js": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.0.4.tgz", + "integrity": "sha512-SudIN9TRJ+v8g5pTG8RRCqfqTMNqgWCKKd3vtynhGzkIIjxaicNAMuY5TRadJ6tzDu3Dotf3ngaMILtmOdmWEQ==", + "dev": true, + "requires": { + "source-map": "^0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "@types/webpack": { + "version": "4.41.8", + "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.8.tgz", + "integrity": "sha512-mh4litLHTlDG84TGCFv1pZldndI34vkrW9Mks++Zx4KET7DRMoCXUvLbTISiuF4++fMgNnhV9cc1nCXJQyBYbQ==", + "dev": true, + "requires": { + "@types/anymatch": "*", + "@types/node": "*", + "@types/tapable": "*", + "@types/uglify-js": "*", + "@types/webpack-sources": "*", + "source-map": "^0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "@types/webpack-sources": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@types/webpack-sources/-/webpack-sources-0.1.7.tgz", + "integrity": "sha512-XyaHrJILjK1VHVC4aVlKsdNN5KBTwufMb43cQs+flGxtPAf/1Qwl8+Q0tp5BwEGaI8D6XT1L+9bSWXckgkjTLw==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/source-list-map": "*", + "source-map": "^0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, "@types/yargs": { "version": "13.0.4", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-13.0.4.tgz", @@ -1338,16 +1391,193 @@ "integrity": "sha512-gCubfBUZ6KxzoibJ+SCUc/57Ms1jz5NjHe4+dI2krNmU5zCPAphyLJYyTOg06ueIyfj+SaCUqmzun7ImlxDcKg==", "dev": true }, - "JSONStream": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", - "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "@webassemblyjs/ast": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz", + "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==", "dev": true, "requires": { - "jsonparse": "^1.2.0", - "through": ">=2.2.7 <3" + "@webassemblyjs/helper-module-context": "1.9.0", + "@webassemblyjs/helper-wasm-bytecode": "1.9.0", + "@webassemblyjs/wast-parser": "1.9.0" } }, + "@webassemblyjs/floating-point-hex-parser": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.9.0.tgz", + "integrity": "sha512-TG5qcFsS8QB4g4MhrxK5TqfdNe7Ey/7YL/xN+36rRjl/BlGE/NcBvJcqsRgCP6Z92mRE+7N50pRIi8SmKUbcQA==", + "dev": true + }, + "@webassemblyjs/helper-api-error": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz", + "integrity": "sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==", + "dev": true + }, + "@webassemblyjs/helper-buffer": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.9.0.tgz", + "integrity": "sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA==", + "dev": true + }, + "@webassemblyjs/helper-code-frame": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.9.0.tgz", + "integrity": "sha512-ERCYdJBkD9Vu4vtjUYe8LZruWuNIToYq/ME22igL+2vj2dQ2OOujIZr3MEFvfEaqKoVqpsFKAGsRdBSBjrIvZA==", + "dev": true, + "requires": { + "@webassemblyjs/wast-printer": "1.9.0" + } + }, + "@webassemblyjs/helper-fsm": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-fsm/-/helper-fsm-1.9.0.tgz", + "integrity": "sha512-OPRowhGbshCb5PxJ8LocpdX9Kl0uB4XsAjl6jH/dWKlk/mzsANvhwbiULsaiqT5GZGT9qinTICdj6PLuM5gslw==", + "dev": true + }, + "@webassemblyjs/helper-module-context": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-module-context/-/helper-module-context-1.9.0.tgz", + "integrity": "sha512-MJCW8iGC08tMk2enck1aPW+BE5Cw8/7ph/VGZxwyvGbJwjktKkDK7vy7gAmMDx88D7mhDTCNKAW5tED+gZ0W8g==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.9.0" + } + }, + "@webassemblyjs/helper-wasm-bytecode": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz", + "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==", + "dev": true + }, + "@webassemblyjs/helper-wasm-section": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.9.0.tgz", + "integrity": "sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.9.0", + "@webassemblyjs/helper-buffer": "1.9.0", + "@webassemblyjs/helper-wasm-bytecode": "1.9.0", + "@webassemblyjs/wasm-gen": "1.9.0" + } + }, + "@webassemblyjs/ieee754": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.9.0.tgz", + "integrity": "sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg==", + "dev": true, + "requires": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "@webassemblyjs/leb128": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.9.0.tgz", + "integrity": "sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw==", + "dev": true, + "requires": { + "@xtuc/long": "4.2.2" + } + }, + "@webassemblyjs/utf8": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.9.0.tgz", + "integrity": "sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w==", + "dev": true + }, + "@webassemblyjs/wasm-edit": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.9.0.tgz", + "integrity": "sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.9.0", + "@webassemblyjs/helper-buffer": "1.9.0", + "@webassemblyjs/helper-wasm-bytecode": "1.9.0", + "@webassemblyjs/helper-wasm-section": "1.9.0", + "@webassemblyjs/wasm-gen": "1.9.0", + "@webassemblyjs/wasm-opt": "1.9.0", + "@webassemblyjs/wasm-parser": "1.9.0", + "@webassemblyjs/wast-printer": "1.9.0" + } + }, + "@webassemblyjs/wasm-gen": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.9.0.tgz", + "integrity": "sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.9.0", + "@webassemblyjs/helper-wasm-bytecode": "1.9.0", + "@webassemblyjs/ieee754": "1.9.0", + "@webassemblyjs/leb128": "1.9.0", + "@webassemblyjs/utf8": "1.9.0" + } + }, + "@webassemblyjs/wasm-opt": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.9.0.tgz", + "integrity": "sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.9.0", + "@webassemblyjs/helper-buffer": "1.9.0", + "@webassemblyjs/wasm-gen": "1.9.0", + "@webassemblyjs/wasm-parser": "1.9.0" + } + }, + "@webassemblyjs/wasm-parser": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.9.0.tgz", + "integrity": "sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.9.0", + "@webassemblyjs/helper-api-error": "1.9.0", + "@webassemblyjs/helper-wasm-bytecode": "1.9.0", + "@webassemblyjs/ieee754": "1.9.0", + "@webassemblyjs/leb128": "1.9.0", + "@webassemblyjs/utf8": "1.9.0" + } + }, + "@webassemblyjs/wast-parser": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-parser/-/wast-parser-1.9.0.tgz", + "integrity": "sha512-qsqSAP3QQ3LyZjNC/0jBJ/ToSxfYJ8kYyuiGvtn/8MK89VrNEfwj7BPQzJVHi0jGTRK2dGdJ5PRqhtjzoww+bw==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.9.0", + "@webassemblyjs/floating-point-hex-parser": "1.9.0", + "@webassemblyjs/helper-api-error": "1.9.0", + "@webassemblyjs/helper-code-frame": "1.9.0", + "@webassemblyjs/helper-fsm": "1.9.0", + "@xtuc/long": "4.2.2" + } + }, + "@webassemblyjs/wast-printer": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz", + "integrity": "sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.9.0", + "@webassemblyjs/wast-parser": "1.9.0", + "@xtuc/long": "4.2.2" + } + }, + "@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, "abab": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.3.tgz", @@ -1361,9 +1591,9 @@ "dev": true }, "acorn": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.1.tgz", - "integrity": "sha512-add7dgA5ppRPxCFJoAGfMDi7PIBXq1RtGo7BhbLaxwrXPOmw8gq48Y9ozT01hUKy9byMjlR20EJhu5zlkErEkg==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.1.tgz", + "integrity": "sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA==", "dev": true }, "acorn-globals": { @@ -1376,12 +1606,6 @@ "acorn-walk": "^6.0.1" }, "dependencies": { - "acorn": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.0.tgz", - "integrity": "sha512-gac8OEcQ2Li1dxIEWGZzsp2BitJxwkwcOm0zHAJLcPJaVvm58FRnk6RkuLRpU1EujipU2ZFODv2P9DLMfnV8mw==", - "dev": true - }, "acorn-walk": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-6.2.0.tgz", @@ -1390,33 +1614,6 @@ } } }, - "acorn-node": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", - "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==", - "dev": true, - "requires": { - "acorn": "^7.0.0", - "acorn-walk": "^7.0.0", - "xtend": "^4.0.2" - } - }, - "acorn-walk": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.0.0.tgz", - "integrity": "sha512-7Bv1We7ZGuU79zZbb6rRqcpxo3OY+zrdtloZWoyD8fmGX+FeXRjE+iuGkZjSXLVovLzrsvMGMy0EkwA0E0umxg==", - "dev": true - }, - "aggregate-error": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.0.1.tgz", - "integrity": "sha512-quoaXsZ9/BLNae5yiNoUz+Nhkwz83GhWwtYFglcjEQB2NDHCIpApbqXxIFnm4Pq/Nvhrsq5sYJFyohrrxnTGAA==", - "dev": true, - "requires": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - } - }, "ajv": { "version": "6.10.2", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", @@ -1429,36 +1626,30 @@ "uri-js": "^4.2.2" } }, + "ajv-errors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz", + "integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==", + "dev": true + }, + "ajv-keywords": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.4.1.tgz", + "integrity": "sha512-RO1ibKvd27e6FEShVFfPALuHI3WjSVNeK5FIsmme/LYRNxjKuNj+Dt7bucLa6NdSv3JcVTyMlm9kGR84z1XpaQ==", + "dev": true + }, "amdefine": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=", "dev": true }, - "ansi-colors": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz", - "integrity": "sha512-SFKX67auSNoVR38N3L+nvsPjOE0bybKTYbkf5tRvushrAPQ9V75huw0ZxBkKVeRU9kqH3d6HA4xTckbwZ4ixmA==", - "dev": true, - "requires": { - "ansi-wrap": "^0.1.0" - } - }, "ansi-escapes": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz", "integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==", "dev": true }, - "ansi-gray": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/ansi-gray/-/ansi-gray-0.1.1.tgz", - "integrity": "sha1-KWLPVOyXksSFEKPetSRDaGHvclE=", - "dev": true, - "requires": { - "ansi-wrap": "0.1.0" - } - }, "ansi-regex": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", @@ -1474,12 +1665,6 @@ "color-convert": "^1.9.0" } }, - "ansi-wrap": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/ansi-wrap/-/ansi-wrap-0.1.0.tgz", - "integrity": "sha1-qCJQ3bABXponyoLoLqYDu/pF768=", - "dev": true - }, "anymatch": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz", @@ -1490,27 +1675,12 @@ "normalize-path": "^2.1.1" } }, - "append-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/append-buffer/-/append-buffer-1.0.2.tgz", - "integrity": "sha1-2CIM9GYIFSXv6lBhTz3mUU36WPE=", - "dev": true, - "requires": { - "buffer-equal": "^1.0.0" - } - }, "aproba": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", "dev": true }, - "archy": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", - "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=", - "dev": true - }, "are-we-there-yet": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz", @@ -1527,42 +1697,18 @@ "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", "dev": true }, - "arr-filter": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/arr-filter/-/arr-filter-1.1.2.tgz", - "integrity": "sha1-Q/3d0JHo7xGqTEXZzcGOLf8XEe4=", - "dev": true, - "requires": { - "make-iterator": "^1.0.0" - } - }, "arr-flatten": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", "dev": true }, - "arr-map": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/arr-map/-/arr-map-2.0.2.tgz", - "integrity": "sha1-Onc0X/wc814qkYJWAfnljy4kysQ=", - "dev": true, - "requires": { - "make-iterator": "^1.0.0" - } - }, "arr-union": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=", "dev": true }, - "array-each": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/array-each/-/array-each-1.0.1.tgz", - "integrity": "sha1-p5SvDAWrF1KEbudTofIRoFugxE8=", - "dev": true - }, "array-equal": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/array-equal/-/array-equal-1.0.0.tgz", @@ -1575,71 +1721,12 @@ "integrity": "sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=", "dev": true }, - "array-initial": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/array-initial/-/array-initial-1.1.0.tgz", - "integrity": "sha1-L6dLJnOTccOUe9enrcc74zSz15U=", - "dev": true, - "requires": { - "array-slice": "^1.0.0", - "is-number": "^4.0.0" - }, - "dependencies": { - "is-number": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-4.0.0.tgz", - "integrity": "sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==", - "dev": true - } - } - }, - "array-last": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/array-last/-/array-last-1.3.0.tgz", - "integrity": "sha512-eOCut5rXlI6aCOS7Z7kCplKRKyiFQ6dHFBem4PwlwKeNFk2/XxTrhRh5T9PyaEWGy/NHTZWbY+nsZlNFJu9rYg==", - "dev": true, - "requires": { - "is-number": "^4.0.0" - }, - "dependencies": { - "is-number": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-4.0.0.tgz", - "integrity": "sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==", - "dev": true - } - } - }, - "array-slice": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz", - "integrity": "sha512-B1qMD3RBP7O8o0H2KbrXDyB0IccejMF15+87Lvlor12ONPRHP6gTjXMNkt/d3ZuOGbAe66hFmaCfECI24Ufp6w==", + "array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=", "dev": true }, - "array-sort": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-sort/-/array-sort-1.0.0.tgz", - "integrity": "sha512-ihLeJkonmdiAsD7vpgN3CRcx2J2S0TiYW+IS/5zHBI7mKUq3ySvBdzzBfD236ubDBQFiiyG3SWCPc+msQ9KoYg==", - "dev": true, - "requires": { - "default-compare": "^1.0.0", - "get-value": "^2.0.6", - "kind-of": "^5.0.2" - }, - "dependencies": { - "kind-of": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", - "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", - "dev": true - } - } - }, - "array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==" - }, "array-unique": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", @@ -1711,18 +1798,6 @@ "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", "dev": true }, - "async-done": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/async-done/-/async-done-1.3.2.tgz", - "integrity": "sha512-uYkTP8dw2og1tu1nmza1n1CMW0qb8gWWlwqMmLb7MhBVs4BXrFziT6HXUd+/RlRA/i4H9AkofYloUbs1fwMqlw==", - "dev": true, - "requires": { - "end-of-stream": "^1.1.0", - "once": "^1.3.2", - "process-nextick-args": "^2.0.0", - "stream-exhaust": "^1.0.1" - } - }, "async-each": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.3.tgz", @@ -1741,15 +1816,6 @@ "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", "dev": true }, - "async-settle": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-settle/-/async-settle-1.0.0.tgz", - "integrity": "sha1-HQqRS7Aldb7IqPOnTlCA9yssDGs=", - "dev": true, - "requires": { - "async-done": "^1.2.2" - } - }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1797,6 +1863,19 @@ } } }, + "babel-loader": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.1.0.tgz", + "integrity": "sha512-7q7nC1tYOrqvUrN3LQK4GwSk/TQorZSOlO9C+RZDZpODgyN4ZlCqE5q9cDsyWOliN+aU9B4JX01xK9eJXowJLw==", + "dev": true, + "requires": { + "find-cache-dir": "^2.1.0", + "loader-utils": "^1.4.0", + "mkdirp": "^0.5.3", + "pify": "^4.0.1", + "schema-utils": "^2.6.5" + } + }, "babel-plugin-dynamic-import-node": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.0.tgz", @@ -1861,29 +1940,6 @@ } } }, - "babelify": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/babelify/-/babelify-10.0.0.tgz", - "integrity": "sha512-X40FaxyH7t3X+JFAKvb1H9wooWKLRCi8pg3m8poqtdZaIng+bjzp9RvKQCvRjF9isHiPkXspbbXT/zwXLtwgwg==", - "dev": true - }, - "bach": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/bach/-/bach-1.2.0.tgz", - "integrity": "sha1-Szzpa/JxNPeaG0FKUcFONMO9mIA=", - "dev": true, - "requires": { - "arr-filter": "^1.1.1", - "arr-flatten": "^1.0.1", - "arr-map": "^2.0.0", - "array-each": "^1.0.0", - "array-initial": "^1.0.0", - "array-last": "^1.1.1", - "async-done": "^1.2.2", - "async-settle": "^1.0.0", - "now-and-later": "^2.0.0" - } - }, "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", @@ -1960,6 +2016,12 @@ "tweetnacl": "^0.14.3" } }, + "big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true + }, "binary-extensions": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz", @@ -1976,16 +2038,6 @@ "file-uri-to-path": "1.0.0" } }, - "bl": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.2.tgz", - "integrity": "sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA==", - "dev": true, - "requires": { - "readable-stream": "^2.3.5", - "safe-buffer": "^5.1.1" - } - }, "block-stream": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz", @@ -1995,6 +2047,12 @@ "inherits": "~2.0.0" } }, + "bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true + }, "bn.js": { "version": "4.11.8", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", @@ -2046,20 +2104,6 @@ "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=", "dev": true }, - "browser-pack": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/browser-pack/-/browser-pack-6.1.0.tgz", - "integrity": "sha512-erYug8XoqzU3IfcU8fUgyHqyOXqIE4tUTTQ+7mqUjQlvnXkOO6OlT9c/ZoJVHYoAaqGxr09CN53G7XIsO4KtWA==", - "dev": true, - "requires": { - "JSONStream": "^1.0.3", - "combine-source-map": "~0.8.0", - "defined": "^1.0.0", - "safe-buffer": "^5.1.1", - "through2": "^2.0.0", - "umd": "^3.0.0" - } - }, "browser-process-hrtime": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-0.1.3.tgz", @@ -2083,62 +2127,6 @@ } } }, - "browserify": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/browserify/-/browserify-16.5.0.tgz", - "integrity": "sha512-6bfI3cl76YLAnCZ75AGu/XPOsqUhRyc0F/olGIJeCxtfxF2HvPKEcmjU9M8oAPxl4uBY1U7Nry33Q6koV3f2iw==", - "dev": true, - "requires": { - "JSONStream": "^1.0.3", - "assert": "^1.4.0", - "browser-pack": "^6.0.1", - "browser-resolve": "^1.11.0", - "browserify-zlib": "~0.2.0", - "buffer": "^5.0.2", - "cached-path-relative": "^1.0.0", - "concat-stream": "^1.6.0", - "console-browserify": "^1.1.0", - "constants-browserify": "~1.0.0", - "crypto-browserify": "^3.0.0", - "defined": "^1.0.0", - "deps-sort": "^2.0.0", - "domain-browser": "^1.2.0", - "duplexer2": "~0.1.2", - "events": "^2.0.0", - "glob": "^7.1.0", - "has": "^1.0.0", - "htmlescape": "^1.1.0", - "https-browserify": "^1.0.0", - "inherits": "~2.0.1", - "insert-module-globals": "^7.0.0", - "labeled-stream-splicer": "^2.0.0", - "mkdirp": "^0.5.0", - "module-deps": "^6.0.0", - "os-browserify": "~0.3.0", - "parents": "^1.0.1", - "path-browserify": "~0.0.0", - "process": "~0.11.0", - "punycode": "^1.3.2", - "querystring-es3": "~0.2.0", - "read-only-stream": "^2.0.0", - "readable-stream": "^2.0.2", - "resolve": "^1.1.4", - "shasum": "^1.0.0", - "shell-quote": "^1.6.1", - "stream-browserify": "^2.0.0", - "stream-http": "^3.0.0", - "string_decoder": "^1.1.1", - "subarg": "^1.0.0", - "syntax-error": "^1.1.1", - "through2": "^2.0.0", - "timers-browserify": "^1.0.1", - "tty-browserify": "0.0.1", - "url": "~0.11.0", - "util": "~0.10.1", - "vm-browserify": "^1.0.0", - "xtend": "^4.0.0" - } - }, "browserify-aes": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", @@ -2230,22 +2218,6 @@ "node-int64": "^0.4.0" } }, - "buffer": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.4.3.tgz", - "integrity": "sha512-zvj65TkFeIt3i6aj5bIvJDzjjQQGs4o/sNoezg1F1kYap9Nu2jcUdpwzRSJTHMMzG0H7bZkn4rNQpImhuxWX2A==", - "dev": true, - "requires": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4" - } - }, - "buffer-equal": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.0.tgz", - "integrity": "sha1-WWFrSYME1Var1GaWayLu2j7KX74=", - "dev": true - }, "buffer-from": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", @@ -2264,6 +2236,61 @@ "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=", "dev": true }, + "cacache": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.4.tgz", + "integrity": "sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==", + "dev": true, + "requires": { + "bluebird": "^3.5.5", + "chownr": "^1.1.1", + "figgy-pudding": "^3.5.1", + "glob": "^7.1.4", + "graceful-fs": "^4.1.15", + "infer-owner": "^1.0.3", + "lru-cache": "^5.1.1", + "mississippi": "^3.0.0", + "mkdirp": "^0.5.1", + "move-concurrently": "^1.0.1", + "promise-inflight": "^1.0.1", + "rimraf": "^2.6.3", + "ssri": "^6.0.1", + "unique-filename": "^1.1.1", + "y18n": "^4.0.0" + }, + "dependencies": { + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "y18n": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", + "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", + "dev": true + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } + }, "cache-base": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", @@ -2281,12 +2308,6 @@ "unset-value": "^1.0.0" } }, - "cached-path-relative": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cached-path-relative/-/cached-path-relative-1.0.2.tgz", - "integrity": "sha512-5r2GqsoEb4qMTTN9J+WzXfjov+hjxT+j3u5K+kIVNIwAd99DLCJE9pBIMP1qVeybV6JiijL385Oz0DcYxfbOIg==", - "dev": true - }, "callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2377,6 +2398,21 @@ } } }, + "chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true + }, + "chrome-trace-event": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.2.tgz", + "integrity": "sha512-9e/zx1jw7B4CO+c/RXoCsfg/x1AfUBioy4owYH0bJprEYAx5hRFLRhWBqHAG57D0ZM4H7vxbP7bPe0VwhQRYDQ==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + }, "ci-info": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", @@ -2416,11 +2452,77 @@ } } }, - "clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true + "clean-webpack-plugin": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/clean-webpack-plugin/-/clean-webpack-plugin-3.0.0.tgz", + "integrity": "sha512-MciirUH5r+cYLGCOL5JX/ZLzOZbVr1ot3Fw+KcvbhUb6PM+yycqd9ZhIlcigQ5gl+XhppNmw3bEFuaaMNyLj3A==", + "dev": true, + "requires": { + "@types/webpack": "^4.4.31", + "del": "^4.1.1" + }, + "dependencies": { + "array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=", + "dev": true, + "requires": { + "array-uniq": "^1.0.1" + } + }, + "del": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/del/-/del-4.1.1.tgz", + "integrity": "sha512-QwGuEUouP2kVwQenAsOof5Fv8K9t3D8Ca8NxcXKrIpEHjTXK5J2nXLdP+ALI1cgv8wj7KuwBhTwBkOZSJKM5XQ==", + "dev": true, + "requires": { + "@types/glob": "^7.1.1", + "globby": "^6.1.0", + "is-path-cwd": "^2.0.0", + "is-path-in-cwd": "^2.0.0", + "p-map": "^2.0.0", + "pify": "^4.0.1", + "rimraf": "^2.6.3" + } + }, + "globby": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz", + "integrity": "sha1-9abXDoOV4hyFj7BInWTfAkJNUGw=", + "dev": true, + "requires": { + "array-union": "^1.0.1", + "glob": "^7.0.3", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + }, + "dependencies": { + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + } + } + }, + "p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } + } }, "cliui": { "version": "3.2.0", @@ -2433,33 +2535,15 @@ "wrap-ansi": "^2.0.0" } }, - "clone": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", - "integrity": "sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=", - "dev": true - }, - "clone-buffer": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/clone-buffer/-/clone-buffer-1.0.0.tgz", - "integrity": "sha1-4+JbIHrE5wGvch4staFnksrD3Fg=", - "dev": true - }, - "clone-stats": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/clone-stats/-/clone-stats-1.0.0.tgz", - "integrity": "sha1-s3gt/4u1R04Yuba/D9/ngvh3doA=", - "dev": true - }, - "cloneable-readable": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/cloneable-readable/-/cloneable-readable-1.1.3.tgz", - "integrity": "sha512-2EF8zTQOxYq70Y4XKtorQupqF0m49MBz2/yf5Bj+MHjvpG3Hy7sImifnqD6UA+TKYxeSV+u6qqQPawN5UvnpKQ==", + "clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", "dev": true, "requires": { - "inherits": "^2.0.1", - "process-nextick-args": "^2.0.0", - "readable-stream": "^2.3.5" + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" } }, "co": { @@ -2474,17 +2558,6 @@ "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", "dev": true }, - "collection-map": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/collection-map/-/collection-map-1.0.0.tgz", - "integrity": "sha1-rqDwb40mx4DCt1SUOFVEsiVa8Yw=", - "dev": true, - "requires": { - "arr-map": "^2.0.2", - "for-own": "^1.0.0", - "make-iterator": "^1.0.0" - } - }, "collection-visit": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", @@ -2510,32 +2583,6 @@ "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", "dev": true }, - "color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "dev": true - }, - "combine-source-map": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/combine-source-map/-/combine-source-map-0.8.0.tgz", - "integrity": "sha1-pY0N8ELBhvz4IqjoAV9UUNLXmos=", - "dev": true, - "requires": { - "convert-source-map": "~1.1.0", - "inline-source-map": "~0.6.0", - "lodash.memoize": "~3.0.3", - "source-map": "~0.5.3" - }, - "dependencies": { - "convert-source-map": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.1.3.tgz", - "integrity": "sha1-SCnId+n+SbMWHzvzZziI4gRpmGA=", - "dev": true - } - } - }, "combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -2545,6 +2592,12 @@ "delayed-stream": "~1.0.0" } }, + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, "commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", @@ -2575,23 +2628,6 @@ "typedarray": "^0.0.6" } }, - "concat-with-sourcemaps": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/concat-with-sourcemaps/-/concat-with-sourcemaps-1.1.0.tgz", - "integrity": "sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg==", - "dev": true, - "requires": { - "source-map": "^0.6.1" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - } - } - }, "console-browserify": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz", @@ -2619,22 +2655,37 @@ "safe-buffer": "~5.1.1" } }, + "copy-concurrently": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz", + "integrity": "sha512-f2domd9fsVDFtaFcbaRZuYXwtdmnzqbADSwhSWYxYB/Q8zsdUUFMXVRwXGDMWmbEzAn1kdRrtI1T/KTFOL4X2A==", + "dev": true, + "requires": { + "aproba": "^1.1.1", + "fs-write-stream-atomic": "^1.0.8", + "iferr": "^0.1.5", + "mkdirp": "^0.5.1", + "rimraf": "^2.5.4", + "run-queue": "^1.0.0" + }, + "dependencies": { + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } + } + }, "copy-descriptor": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=", "dev": true }, - "copy-props": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/copy-props/-/copy-props-2.0.4.tgz", - "integrity": "sha512-7cjuUME+p+S3HZlbllgsn2CDwS+5eCCX16qBgNC4jgSTf49qR1VKy/Zhl400m0IQXl/bPGEVqncgUUMjrr4s8A==", - "dev": true, - "requires": { - "each-props": "^1.3.0", - "is-plain-object": "^2.0.1" - } - }, "core-js": { "version": "3.6.1", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.1.tgz", @@ -2731,6 +2782,46 @@ "randomfill": "^1.0.3" } }, + "css-loader": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-3.4.2.tgz", + "integrity": "sha512-jYq4zdZT0oS0Iykt+fqnzVLRIeiPWhka+7BqPn+oSIpWJAHak5tmB/WZrJ2a21JhCeFyNnnlroSl8c+MtVndzA==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "cssesc": "^3.0.0", + "icss-utils": "^4.1.1", + "loader-utils": "^1.2.3", + "normalize-path": "^3.0.0", + "postcss": "^7.0.23", + "postcss-modules-extract-imports": "^2.0.0", + "postcss-modules-local-by-default": "^3.0.2", + "postcss-modules-scope": "^2.1.1", + "postcss-modules-values": "^3.0.0", + "postcss-value-parser": "^4.0.2", + "schema-utils": "^2.6.0" + }, + "dependencies": { + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + } + } + }, + "cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true + }, "cssom": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", @@ -2755,20 +2846,10 @@ "array-find-index": "^1.0.1" } }, - "d": { + "cyclist": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", - "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", - "dev": true, - "requires": { - "es5-ext": "^0.10.50", - "type": "^1.0.1" - } - }, - "dash-ast": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/dash-ast/-/dash-ast-1.0.0.tgz", - "integrity": "sha512-Vy4dx7gquTeMcQR/hDkYLGUnwVil6vk4FOOct+djUnHOUWt+zJPJAaRIXaAFkPXtJjvlY7o3rfRu0/3hpnwoUA==", + "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz", + "integrity": "sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=", "dev": true }, "dashdash": { @@ -2836,29 +2917,6 @@ "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", "dev": true }, - "default-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/default-compare/-/default-compare-1.0.0.tgz", - "integrity": "sha512-QWfXlM0EkAbqOCbD/6HjdwT19j7WCkMyiRhWilc4H9/5h/RzTF9gv5LYh1+CmDV5d1rki6KAWLtQale0xt20eQ==", - "dev": true, - "requires": { - "kind-of": "^5.0.2" - }, - "dependencies": { - "kind-of": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", - "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", - "dev": true - } - } - }, - "default-resolution": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/default-resolution/-/default-resolution-2.0.0.tgz", - "integrity": "sha1-vLgrqnKtebQmp2cy8aga1t8m1oQ=", - "dev": true - }, "define-properties": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", @@ -2909,52 +2967,6 @@ } } }, - "defined": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz", - "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=", - "dev": true - }, - "del": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/del/-/del-5.1.0.tgz", - "integrity": "sha512-wH9xOVHnczo9jN2IW68BabcecVPxacIA3g/7z6vhSU/4stOKQzeCRK0yD0A24WiAAUJmmVpWqrERcTxnLo3AnA==", - "dev": true, - "requires": { - "globby": "^10.0.1", - "graceful-fs": "^4.2.2", - "is-glob": "^4.0.1", - "is-path-cwd": "^2.2.0", - "is-path-inside": "^3.0.1", - "p-map": "^3.0.0", - "rimraf": "^3.0.0", - "slash": "^3.0.0" - }, - "dependencies": { - "globby": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/globby/-/globby-10.0.2.tgz", - "integrity": "sha512-7dUi7RvCoT/xast/o/dLN53oqND4yk0nsHkhRgn9w65C4PofCLOoJ39iSOg+qVDdWQPIEj+eszMHQ+aLVwwQSg==", - "dev": true, - "requires": { - "@types/glob": "^7.1.1", - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.0.3", - "glob": "^7.1.3", - "ignore": "^5.1.1", - "merge2": "^1.2.3", - "slash": "^3.0.0" - } - }, - "graceful-fs": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz", - "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==", - "dev": true - } - } - }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2967,18 +2979,6 @@ "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", "dev": true }, - "deps-sort": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/deps-sort/-/deps-sort-2.0.1.tgz", - "integrity": "sha512-1orqXQr5po+3KI6kQb9A4jnXT1PBwggGl2d7Sq2xsnOeI9GPcE/tGcF9UiSZtZBM7MukY4cAh7MemS6tZYipfw==", - "dev": true, - "requires": { - "JSONStream": "^1.0.3", - "shasum-object": "^1.0.0", - "subarg": "^1.0.0", - "through2": "^2.0.0" - } - }, "des.js": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.1.tgz", @@ -3001,17 +3001,6 @@ "integrity": "sha1-9B8cEL5LAOh7XxPaaAdZ8sW/0+I=", "dev": true }, - "detective": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz", - "integrity": "sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==", - "dev": true, - "requires": { - "acorn-node": "^1.6.1", - "defined": "^1.0.0", - "minimist": "^1.1.1" - } - }, "diff-sequences": { "version": "24.9.0", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-24.9.0.tgz", @@ -3029,21 +3018,6 @@ "randombytes": "^2.0.0" } }, - "dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "requires": { - "path-type": "^4.0.0" - }, - "dependencies": { - "path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==" - } - } - }, "domain-browser": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz", @@ -3059,15 +3033,6 @@ "webidl-conversions": "^4.0.2" } }, - "duplexer2": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", - "integrity": "sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=", - "dev": true, - "requires": { - "readable-stream": "^2.0.2" - } - }, "duplexify": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", @@ -3080,16 +3045,6 @@ "stream-shift": "^1.0.0" } }, - "each-props": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/each-props/-/each-props-1.3.2.tgz", - "integrity": "sha512-vV0Hem3zAGkJAyU7JSjixeU66rwdynTAa1vofCrSA5fEln+m67Az9CcnkVD776/fsN/UjIWmBDoNRS6t6G9RfA==", - "dev": true, - "requires": { - "is-plain-object": "^2.0.1", - "object.defaults": "^1.1.0" - } - }, "ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -3127,6 +3082,12 @@ "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", "dev": true }, + "emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true + }, "end-of-stream": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz", @@ -3136,6 +3097,38 @@ "once": "^1.4.0" } }, + "enhanced-resolve": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.1.1.tgz", + "integrity": "sha512-98p2zE+rL7/g/DzMHMTF4zZlCgeVdJ7yr6xzEpJRYwFYrGi9ANdn5DnJURg6RpBkyk60XYDnWIv51VfIhfNGuA==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "memory-fs": "^0.5.0", + "tapable": "^1.0.0" + }, + "dependencies": { + "memory-fs": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz", + "integrity": "sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==", + "dev": true, + "requires": { + "errno": "^0.1.3", + "readable-stream": "^2.0.1" + } + } + } + }, + "errno": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.7.tgz", + "integrity": "sha512-MfrRBDWzIWifgq6tJj60gkAwtLNb6sQPlcFrSOflcP1aFmmruKQ2wRnze/8V6kgyz7H3FF8Npzv78mZ7XLLflg==", + "dev": true, + "requires": { + "prr": "~1.0.1" + } + }, "error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -3183,50 +3176,6 @@ "is-symbol": "^1.0.2" } }, - "es5-ext": { - "version": "0.10.50", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.50.tgz", - "integrity": "sha512-KMzZTPBkeQV/JcSQhI5/z6d9VWJ3EnQ194USTUwIYZ2ZbpN8+SGXQKt1h68EX44+qt+Fzr8DO17vnxrw7c3agw==", - "dev": true, - "requires": { - "es6-iterator": "~2.0.3", - "es6-symbol": "~3.1.1", - "next-tick": "^1.0.0" - } - }, - "es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=", - "dev": true, - "requires": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "es6-symbol": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.1.tgz", - "integrity": "sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc=", - "dev": true, - "requires": { - "d": "1", - "es5-ext": "~0.10.14" - } - }, - "es6-weak-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", - "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", - "dev": true, - "requires": { - "d": "1", - "es5-ext": "^0.10.46", - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.1" - } - }, "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -3255,12 +3204,31 @@ } } }, + "eslint-scope": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz", + "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==", + "dev": true, + "requires": { + "esrecurse": "^4.1.0", + "estraverse": "^4.1.1" + } + }, "esprima": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/esprima/-/esprima-3.1.3.tgz", "integrity": "sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM=", "dev": true }, + "esrecurse": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.1.tgz", + "integrity": "sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==", + "dev": true, + "requires": { + "estraverse": "^4.1.0" + } + }, "estraverse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", @@ -3273,12 +3241,6 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, - "events": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/events/-/events-2.1.0.tgz", - "integrity": "sha512-3Zmiobend8P9DjmKAty0Era4jV8oJ0yGYe2nJJAxgymF9+N8F2m0hhZiMoWtcfepExzNKZumFU3ksdQbInGWCg==", - "dev": true - }, "evp_bytestokey": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", @@ -3487,84 +3449,12 @@ "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", "dev": true }, - "fancy-log": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/fancy-log/-/fancy-log-1.3.3.tgz", - "integrity": "sha512-k9oEhlyc0FrVh25qYuSELjr8oxsCoc4/LEZfg2iJJrfEk/tZL9bCoJE47gqAvI2m/AUjluCS4+3I0eTx8n3AEw==", - "dev": true, - "requires": { - "ansi-gray": "^0.1.1", - "color-support": "^1.1.3", - "parse-node-version": "^1.0.0", - "time-stamp": "^1.0.0" - } - }, "fast-deep-equal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", "dev": true }, - "fast-glob": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.1.1.tgz", - "integrity": "sha512-nTCREpBY8w8r+boyFYAx21iL6faSsQynliPHM4Uf56SbkyohCNxpVPEH9xrF5TXKy+IsjkPUHDKiUkzBVRXn9g==", - "requires": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.0", - "merge2": "^1.3.0", - "micromatch": "^4.0.2" - }, - "dependencies": { - "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "requires": { - "fill-range": "^7.0.1" - } - }, - "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "requires": { - "to-regex-range": "^5.0.1" - } - }, - "glob-parent": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.0.tgz", - "integrity": "sha512-qjtRgnIVmOfnKUE3NJAQEdk+lKrxfw8t5ke7SXtfMTHcjsBfOfWXCQfdb30zfDoZQ2IRSIiidmjtbHZPZ++Ihw==", - "requires": { - "is-glob": "^4.0.1" - } - }, - "is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" - }, - "micromatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz", - "integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==", - "requires": { - "braces": "^3.0.1", - "picomatch": "^2.0.5" - } - }, - "to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "requires": { - "is-number": "^7.0.0" - } - } - } - }, "fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3577,20 +3467,6 @@ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true }, - "fast-safe-stringify": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz", - "integrity": "sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA==", - "dev": true - }, - "fastq": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.6.0.tgz", - "integrity": "sha512-jmxqQ3Z/nXoeyDmWAzF9kH1aGZSis6e/SbfPmJpUnyZ0ogr6iscHQaml4wsEepEWSdtmpy+eVXmCRIMpxaXqOA==", - "requires": { - "reusify": "^1.0.0" - } - }, "fb-watchman": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.1.tgz", @@ -3615,6 +3491,12 @@ "whatwg-url": "^6.5.0" } }, + "figgy-pudding": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz", + "integrity": "sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw==", + "dev": true + }, "file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -3677,25 +3559,6 @@ "resolve-dir": "^1.0.1" } }, - "fined": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fined/-/fined-1.2.0.tgz", - "integrity": "sha512-ZYDqPLGxDkDhDZBjZBb+oD1+j0rA4E0pXY50eplAAOPg2N/gUBSSk5IM1/QhPfyVo19lJ+CvXpqfvk+b2p/8Ng==", - "dev": true, - "requires": { - "expand-tilde": "^2.0.2", - "is-plain-object": "^2.0.3", - "object.defaults": "^1.1.0", - "object.pick": "^1.2.0", - "parse-filepath": "^1.0.1" - } - }, - "flagged-respawn": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-1.0.1.tgz", - "integrity": "sha512-lNaHNVymajmk0OJMBn8fVUAU1BtDeKIqKoVhk4xAALB57aALg6b4W0MfJ/cUE0g9YBXy5XhSlPIpYIJ7HaY/3Q==", - "dev": true - }, "flush-write-stream": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.1.1.tgz", @@ -3712,15 +3575,6 @@ "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=", "dev": true }, - "for-own": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz", - "integrity": "sha1-xjMy9BXO3EsE2/5wz4NklMU8tEs=", - "dev": true, - "requires": { - "for-in": "^1.0.1" - } - }, "forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", @@ -3747,14 +3601,26 @@ "map-cache": "^0.2.2" } }, - "fs-mkdirp-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-mkdirp-stream/-/fs-mkdirp-stream-1.0.0.tgz", - "integrity": "sha1-C3gV/DIBxqaeFNuYzgmMFpNSWes=", + "from2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", + "integrity": "sha1-i/tVAr3kpNNs/e6gB/zKIdfjgq8=", "dev": true, "requires": { - "graceful-fs": "^4.1.11", - "through2": "^2.0.3" + "inherits": "^2.0.1", + "readable-stream": "^2.0.0" + } + }, + "fs-write-stream-atomic": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz", + "integrity": "sha1-tH31NJPvkR33VzHnCp3tAYnbQMk=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "iferr": "^0.1.5", + "imurmurhash": "^0.1.4", + "readable-stream": "1 || 2" } }, "fs.realpath": { @@ -4368,12 +4234,6 @@ "globule": "^1.0.0" } }, - "get-assigned-identifiers": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/get-assigned-identifiers/-/get-assigned-identifiers-1.2.0.tgz", - "integrity": "sha512-mBBwmeGTrxEMO4pMaaf/uUEFHnYtwr8FTe8Y/mer4rcV/bye0qGm6pw1bGZFGStxC5O76c5ZAVBGnqHmOaJpdQ==", - "dev": true - }, "get-caller-file": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", @@ -4457,44 +4317,12 @@ } } }, - "glob-stream": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/glob-stream/-/glob-stream-6.1.0.tgz", - "integrity": "sha1-cEXJlBOz65SIjYOrRtC0BMx73eQ=", - "dev": true, - "requires": { - "extend": "^3.0.0", - "glob": "^7.1.1", - "glob-parent": "^3.1.0", - "is-negated-glob": "^1.0.0", - "ordered-read-streams": "^1.0.0", - "pumpify": "^1.3.5", - "readable-stream": "^2.1.5", - "remove-trailing-separator": "^1.0.1", - "to-absolute-glob": "^2.0.0", - "unique-stream": "^2.0.2" - } - }, "glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "dev": true }, - "glob-watcher": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/glob-watcher/-/glob-watcher-5.0.3.tgz", - "integrity": "sha512-8tWsULNEPHKQ2MR4zXuzSmqbdyV5PtwwCaWSGQ1WwHsJ07ilNeN1JB8ntxhckbnpSHaf9dXFUHzIWvm1I13dsg==", - "dev": true, - "requires": { - "anymatch": "^2.0.0", - "async-done": "^1.2.0", - "chokidar": "^2.0.0", - "is-negated-glob": "^1.0.0", - "just-debounce": "^1.0.0", - "object.defaults": "^1.1.0" - } - }, "global-modules": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", @@ -4525,19 +4353,6 @@ "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", "dev": true }, - "globby": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.0.tgz", - "integrity": "sha512-iuehFnR3xu5wBBtm4xi0dMe92Ob87ufyu/dHwpDYfbcpYpIbrO5OnS8M1vWvrBhSGEJ3/Ecj7gnX76P8YxpPEg==", - "requires": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.1.1", - "ignore": "^5.1.4", - "merge2": "^1.3.0", - "slash": "^3.0.0" - } - }, "globule": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/globule/-/globule-1.3.1.tgz", @@ -4549,15 +4364,6 @@ "minimatch": "~3.0.2" } }, - "glogg": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/glogg/-/glogg-1.0.2.tgz", - "integrity": "sha512-5mwUoSuBk44Y4EshyiqcH95ZntbDdTQqA3QYSrxmzj28Ai0vXBGMH1ApSANH14j2sIRtqCEyg6PfsuP7ElOEDA==", - "dev": true, - "requires": { - "sparkles": "^1.0.0" - } - }, "graceful-fs": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.0.tgz", @@ -4570,109 +4376,6 @@ "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", "dev": true }, - "gulp": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/gulp/-/gulp-4.0.2.tgz", - "integrity": "sha512-dvEs27SCZt2ibF29xYgmnwwCYZxdxhQ/+LFWlbAW8y7jt68L/65402Lz3+CKy0Ov4rOs+NERmDq7YlZaDqUIfA==", - "dev": true, - "requires": { - "glob-watcher": "^5.0.3", - "gulp-cli": "^2.2.0", - "undertaker": "^1.2.1", - "vinyl-fs": "^3.0.0" - } - }, - "gulp-babel": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/gulp-babel/-/gulp-babel-8.0.0.tgz", - "integrity": "sha512-oomaIqDXxFkg7lbpBou/gnUkX51/Y/M2ZfSjL2hdqXTAlSWZcgZtd2o0cOH0r/eE8LWD0+Q/PsLsr2DKOoqToQ==", - "dev": true, - "requires": { - "plugin-error": "^1.0.1", - "replace-ext": "^1.0.0", - "through2": "^2.0.0", - "vinyl-sourcemaps-apply": "^0.2.0" - } - }, - "gulp-cli": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/gulp-cli/-/gulp-cli-2.2.0.tgz", - "integrity": "sha512-rGs3bVYHdyJpLqR0TUBnlcZ1O5O++Zs4bA0ajm+zr3WFCfiSLjGwoCBqFs18wzN+ZxahT9DkOK5nDf26iDsWjA==", - "dev": true, - "requires": { - "ansi-colors": "^1.0.1", - "archy": "^1.0.0", - "array-sort": "^1.0.0", - "color-support": "^1.1.3", - "concat-stream": "^1.6.0", - "copy-props": "^2.0.1", - "fancy-log": "^1.3.2", - "gulplog": "^1.0.0", - "interpret": "^1.1.0", - "isobject": "^3.0.1", - "liftoff": "^3.1.0", - "matchdep": "^2.0.0", - "mute-stdout": "^1.0.0", - "pretty-hrtime": "^1.0.0", - "replace-homedir": "^1.0.0", - "semver-greatest-satisfied-range": "^1.1.0", - "v8flags": "^3.0.1", - "yargs": "^7.1.0" - } - }, - "gulp-concat": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/gulp-concat/-/gulp-concat-2.6.1.tgz", - "integrity": "sha1-Yz0WyV2IUEYorQJmVmPO5aR5M1M=", - "dev": true, - "requires": { - "concat-with-sourcemaps": "^1.0.0", - "through2": "^2.0.0", - "vinyl": "^2.0.0" - } - }, - "gulp-sass": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/gulp-sass/-/gulp-sass-4.0.2.tgz", - "integrity": "sha512-q8psj4+aDrblJMMtRxihNBdovfzGrXJp1l4JU0Sz4b/Mhsi2DPrKFYCGDwjIWRENs04ELVHxdOJQ7Vs98OFohg==", - "dev": true, - "requires": { - "chalk": "^2.3.0", - "lodash.clonedeep": "^4.3.2", - "node-sass": "^4.8.3", - "plugin-error": "^1.0.1", - "replace-ext": "^1.0.0", - "strip-ansi": "^4.0.0", - "through2": "^2.0.0", - "vinyl-sourcemaps-apply": "^0.2.0" - }, - "dependencies": { - "ansi-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", - "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", - "dev": true - }, - "strip-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", - "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", - "dev": true, - "requires": { - "ansi-regex": "^3.0.0" - } - } - } - }, - "gulplog": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/gulplog/-/gulplog-1.0.0.tgz", - "integrity": "sha1-4oxNRdBey77YGDY86PnFkmIp/+U=", - "dev": true, - "requires": { - "glogg": "^1.0.0" - } - }, "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", @@ -4826,12 +4529,6 @@ "integrity": "sha512-hNX23TjWwD3q56HpWjUHOKj1+4KKlnjv9PcmBUYKVpga+2cnb9nDx/B1o0yO4n+RZXZdiNxzx6B24C9aNMTkkQ==", "dev": true }, - "htmlescape": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/htmlescape/-/htmlescape-1.1.1.tgz", - "integrity": "sha1-OgPtwiFLyjtmQko+eVk0lQnLA1E=", - "dev": true - }, "http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", @@ -4858,16 +4555,26 @@ "safer-buffer": ">= 2.1.2 < 3" } }, + "icss-utils": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-4.1.1.tgz", + "integrity": "sha512-4aFq7wvWyMHKgxsH8QQtGpvbASCf+eM3wPRLI6R+MgAnTCZ6STYsRvttLvRWK0Nfif5piF394St3HeJDaljGPA==", + "dev": true, + "requires": { + "postcss": "^7.0.14" + } + }, "ieee754": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==", "dev": true }, - "ignore": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.4.tgz", - "integrity": "sha512-MzbUSahkTW1u7JpKKjY7LCARd1fU5W2rLdxlM4kdkayuCwZImjkpluF9CM1aLewYJguPDqewLam18Y6AU69A8A==" + "iferr": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/iferr/-/iferr-0.1.5.tgz", + "integrity": "sha1-xg7taebY/bazEEofy8ocGS3FtQE=", + "dev": true }, "import-local": { "version": "2.0.0", @@ -4891,10 +4598,16 @@ "integrity": "sha512-oDM0kUSNFC31ShNxHKUyfZKy8ZeXZBWMjMdZHKLOk13uvT27VTL/QzRGfRUcevJhpkZAvlhPYuXkF7eNWrtyxQ==", "dev": true }, - "indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "indexes-of": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz", + "integrity": "sha1-8w9xbI4r00bHtn0985FVZqfAVgc=", + "dev": true + }, + "infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", "dev": true }, "inflight": { @@ -4919,33 +4632,6 @@ "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", "dev": true }, - "inline-source-map": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/inline-source-map/-/inline-source-map-0.6.2.tgz", - "integrity": "sha1-+Tk0ccGKedFyT4Y/o4tYY3Ct4qU=", - "dev": true, - "requires": { - "source-map": "~0.5.3" - } - }, - "insert-module-globals": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/insert-module-globals/-/insert-module-globals-7.2.0.tgz", - "integrity": "sha512-VE6NlW+WGn2/AeOMd496AHFYmE7eLKkUY6Ty31k4og5vmA3Fjuwe9v6ifH6Xx/Hz27QvdoMoviw1/pqWRB09Sw==", - "dev": true, - "requires": { - "JSONStream": "^1.0.3", - "acorn-node": "^1.5.2", - "combine-source-map": "^0.8.0", - "concat-stream": "^1.6.1", - "is-buffer": "^1.1.0", - "path-is-absolute": "^1.0.1", - "process": "~0.11.0", - "through2": "^2.0.0", - "undeclared-identifiers": "^1.1.2", - "xtend": "^4.0.0" - } - }, "interpret": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.2.0.tgz", @@ -4966,16 +4652,6 @@ "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=", "dev": true }, - "is-absolute": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", - "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==", - "dev": true, - "requires": { - "is-relative": "^1.0.0", - "is-windows": "^1.0.1" - } - }, "is-accessor-descriptor": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", @@ -5086,7 +4762,8 @@ "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=" + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true }, "is-finite": { "version": "1.1.0", @@ -5110,16 +4787,11 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "dev": true, "requires": { "is-extglob": "^2.1.1" } }, - "is-negated-glob": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-negated-glob/-/is-negated-glob-1.0.0.tgz", - "integrity": "sha1-aRC8pdqMleeEtXUbl2z1oQ/uNtI=", - "dev": true - }, "is-number": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", @@ -5146,10 +4818,30 @@ "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", "dev": true }, - "is-path-inside": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.2.tgz", - "integrity": "sha512-/2UGPSgmtqwo1ktx8NDHjuPwZWmHhO+gj0f93EkhLB5RgW9RZevWYYlIkS6zePc6U2WpOdQYIwHe9YC4DWEBVg==", + "is-path-in-cwd": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-2.1.0.tgz", + "integrity": "sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ==", + "dev": true, + "requires": { + "is-path-inside": "^2.1.0" + }, + "dependencies": { + "is-path-inside": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-2.1.0.tgz", + "integrity": "sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg==", + "dev": true, + "requires": { + "path-is-inside": "^1.0.2" + } + } + } + }, + "is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=", "dev": true }, "is-plain-object": { @@ -5170,15 +4862,6 @@ "has": "^1.0.3" } }, - "is-relative": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", - "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==", - "dev": true, - "requires": { - "is-unc-path": "^1.0.0" - } - }, "is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", @@ -5208,27 +4891,12 @@ "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", "dev": true }, - "is-unc-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", - "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==", - "dev": true, - "requires": { - "unc-path-regex": "^0.1.2" - } - }, "is-utf8": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=", "dev": true }, - "is-valid-glob": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-valid-glob/-/is-valid-glob-1.0.0.tgz", - "integrity": "sha1-Kb8+/3Ab4tTTFdusw5vDn+j2Aao=", - "dev": true - }, "is-windows": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", @@ -6146,21 +5814,6 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, - "json-stable-stringify": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-0.0.1.tgz", - "integrity": "sha1-YRwj6BTbN1Un34URk9tZ3Sryf0U=", - "dev": true, - "requires": { - "jsonify": "~0.0.0" - } - }, - "json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", - "dev": true - }, "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -6176,18 +5829,6 @@ "minimist": "^1.2.0" } }, - "jsonify": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", - "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=", - "dev": true - }, - "jsonparse": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=", - "dev": true - }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", @@ -6200,12 +5841,6 @@ "verror": "1.10.0" } }, - "just-debounce": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/just-debounce/-/just-debounce-1.0.0.tgz", - "integrity": "sha1-h/zPrv/AtozRnVX2cilD+SnqNeo=", - "dev": true - }, "kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -6218,35 +5853,6 @@ "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", "dev": true }, - "labeled-stream-splicer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/labeled-stream-splicer/-/labeled-stream-splicer-2.0.2.tgz", - "integrity": "sha512-Ca4LSXFFZUjPScRaqOcFxneA0VpKZr4MMYCljyQr4LIewTLb3Y0IUTIsnBBsVubIeEfxeSZpSjSsRM8APEQaAw==", - "dev": true, - "requires": { - "inherits": "^2.0.1", - "stream-splicer": "^2.0.0" - } - }, - "last-run": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/last-run/-/last-run-1.1.1.tgz", - "integrity": "sha1-RblpQsF7HHnHchmCWbqUO+v4yls=", - "dev": true, - "requires": { - "default-resolution": "^2.0.0", - "es6-weak-map": "^2.0.1" - } - }, - "lazystream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.0.tgz", - "integrity": "sha1-9plf4PggOS9hOWvolGJAe7dxaOQ=", - "dev": true, - "requires": { - "readable-stream": "^2.0.5" - } - }, "lcid": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", @@ -6256,15 +5862,6 @@ "invert-kv": "^1.0.0" } }, - "lead": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/lead/-/lead-1.0.0.tgz", - "integrity": "sha1-bxT5mje+Op3XhPVJVpDlkDRm7kI=", - "dev": true, - "requires": { - "flush-write-stream": "^1.0.2" - } - }, "left-pad": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", @@ -6287,22 +5884,6 @@ "type-check": "~0.3.2" } }, - "liftoff": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-3.1.0.tgz", - "integrity": "sha512-DlIPlJUkCV0Ips2zf2pJP0unEoT1kwYhiiPUGF3s/jtxTCjziNLoiVVh+jqWOWeFi6mmwQ5fNxvAUyPad4Dfog==", - "dev": true, - "requires": { - "extend": "^3.0.0", - "findup-sync": "^3.0.0", - "fined": "^1.0.1", - "flagged-respawn": "^1.0.0", - "is-plain-object": "^2.0.4", - "object.map": "^1.0.0", - "rechoir": "^0.6.2", - "resolve": "^1.1.7" - } - }, "load-json-file": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", @@ -6324,6 +5905,34 @@ } } }, + "loader-runner": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz", + "integrity": "sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==", + "dev": true + }, + "loader-utils": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz", + "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^1.0.1" + }, + "dependencies": { + "json5": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + } + } + }, "locate-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", @@ -6339,12 +5948,6 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" }, - "lodash.clonedeep": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", - "dev": true - }, "lodash.isequal": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", @@ -6357,12 +5960,6 @@ "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=", "dev": true }, - "lodash.memoize": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-3.0.4.tgz", - "integrity": "sha1-LcvSwofLwKVcxCMovQxzYVDVPj8=", - "dev": true - }, "lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -6407,15 +6004,6 @@ "semver": "^5.6.0" } }, - "make-iterator": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/make-iterator/-/make-iterator-1.0.1.tgz", - "integrity": "sha512-pxiuXh0iVEq7VM7KMIhs5gxsfxCux2URptUQaXo4iZZJxBAzTPOLE2BumO5dbfVYq/hBJFBR/a1mFDmOx5AGmw==", - "dev": true, - "requires": { - "kind-of": "^6.0.2" - } - }, "makeerror": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.11.tgz", @@ -6425,6 +6013,15 @@ "tmpl": "1.0.x" } }, + "map-age-cleaner": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz", + "integrity": "sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==", + "dev": true, + "requires": { + "p-defer": "^1.0.0" + } + }, "map-cache": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", @@ -6446,41 +6043,6 @@ "object-visit": "^1.0.0" } }, - "matchdep": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/matchdep/-/matchdep-2.0.0.tgz", - "integrity": "sha1-xvNINKDY28OzfCfui7yyfHd1WC4=", - "dev": true, - "requires": { - "findup-sync": "^2.0.0", - "micromatch": "^3.0.4", - "resolve": "^1.4.0", - "stack-trace": "0.0.10" - }, - "dependencies": { - "findup-sync": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-2.0.0.tgz", - "integrity": "sha1-kyaxSIwi0aYIhlCoaQGy2akKLLw=", - "dev": true, - "requires": { - "detect-file": "^1.0.0", - "is-glob": "^3.1.0", - "micromatch": "^3.0.4", - "resolve-dir": "^1.0.1" - } - }, - "is-glob": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", - "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", - "dev": true, - "requires": { - "is-extglob": "^2.1.0" - } - } - } - }, "md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -6492,6 +6054,27 @@ "safe-buffer": "^5.1.2" } }, + "mem": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/mem/-/mem-4.3.0.tgz", + "integrity": "sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w==", + "dev": true, + "requires": { + "map-age-cleaner": "^0.1.1", + "mimic-fn": "^2.0.0", + "p-is-promise": "^2.0.0" + } + }, + "memory-fs": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz", + "integrity": "sha1-OpoguEYlI+RHz7x+i7gO1me/xVI=", + "dev": true, + "requires": { + "errno": "^0.1.3", + "readable-stream": "^2.0.1" + } + }, "meow": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz", @@ -6516,11 +6099,6 @@ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "dev": true }, - "merge2": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.3.0.tgz", - "integrity": "sha512-2j4DAdlBOkiSZIsaXk4mTE3sRS02yBHAtfy127xRV3bQUFqXkjHCHLW6Scv7DwNRbIWNHH8zpnz9zMaKXIdvYw==" - }, "micromatch": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", @@ -6567,6 +6145,37 @@ "mime-db": "1.42.0" } }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, + "mini-css-extract-plugin": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.9.0.tgz", + "integrity": "sha512-lp3GeY7ygcgAmVIcRPBVhIkf8Us7FZjA+ILpal44qLdSu11wmjKQ3d9k15lfD7pO4esu9eUIAW7qiYIBppv40A==", + "dev": true, + "requires": { + "loader-utils": "^1.1.0", + "normalize-url": "1.9.1", + "schema-utils": "^1.0.0", + "webpack-sources": "^1.1.0" + }, + "dependencies": { + "schema-utils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz", + "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==", + "dev": true, + "requires": { + "ajv": "^6.1.0", + "ajv-errors": "^1.0.0", + "ajv-keywords": "^3.1.0" + } + } + } + }, "minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -6594,6 +6203,36 @@ "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", "dev": true }, + "mississippi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mississippi/-/mississippi-3.0.0.tgz", + "integrity": "sha512-x471SsVjUtBRtcvd4BzKE9kFC+/2TeWgKCgw0bZcw1b9l2X3QX5vCWgF+KaZaYm87Ss//rHnWryupDrgLvmSkA==", + "dev": true, + "requires": { + "concat-stream": "^1.5.0", + "duplexify": "^3.4.2", + "end-of-stream": "^1.1.0", + "flush-write-stream": "^1.0.0", + "from2": "^2.1.0", + "parallel-transform": "^1.1.0", + "pump": "^3.0.0", + "pumpify": "^1.3.3", + "stream-each": "^1.1.0", + "through2": "^2.0.0" + }, + "dependencies": { + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + } + } + }, "mixin-deep": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", @@ -6624,27 +6263,29 @@ "minimist": "^1.2.5" } }, - "module-deps": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/module-deps/-/module-deps-6.2.2.tgz", - "integrity": "sha512-a9y6yDv5u5I4A+IPHTnqFxcaKr4p50/zxTjcQJaX2ws9tN/W6J6YXnEKhqRyPhl494dkcxx951onSKVezmI+3w==", + "move-concurrently": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", + "integrity": "sha1-viwAX9oy4LKa8fBdfEszIUxwH5I=", "dev": true, "requires": { - "JSONStream": "^1.0.3", - "browser-resolve": "^1.7.0", - "cached-path-relative": "^1.0.2", - "concat-stream": "~1.6.0", - "defined": "^1.0.0", - "detective": "^5.2.0", - "duplexer2": "^0.1.2", - "inherits": "^2.0.1", - "parents": "^1.0.0", - "readable-stream": "^2.0.2", - "resolve": "^1.4.0", - "stream-combiner2": "^1.1.1", - "subarg": "^1.0.0", - "through2": "^2.0.0", - "xtend": "^4.0.0" + "aproba": "^1.1.1", + "copy-concurrently": "^1.0.0", + "fs-write-stream-atomic": "^1.0.8", + "mkdirp": "^0.5.1", + "rimraf": "^2.5.4", + "run-queue": "^1.0.3" + }, + "dependencies": { + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } } }, "ms": { @@ -6653,12 +6294,6 @@ "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", "dev": true }, - "mute-stdout": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mute-stdout/-/mute-stdout-1.0.1.tgz", - "integrity": "sha512-kDcwXR4PS7caBpuRYYBUz9iVixUk3anO3f5OYFiIPwK/20vCzKCHyKoulbiDY1S53zD2bxUpxN/IJ+TnXjfvxg==", - "dev": true - }, "nan": { "version": "2.14.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz", @@ -6690,10 +6325,10 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, - "next-tick": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", - "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=", + "neo-async": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.1.tgz", + "integrity": "sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw==", "dev": true }, "nice-try": { @@ -6751,6 +6386,101 @@ "integrity": "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=", "dev": true }, + "node-libs-browser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz", + "integrity": "sha512-h/zcD8H9kaDZ9ALUWwlBUDo6TKF8a7qBSCSEGfjTVIYeqsioSKaAX+BN7NgiMGp6iSIXZ3PxgCu8KS3b71YK5Q==", + "dev": true, + "requires": { + "assert": "^1.1.1", + "browserify-zlib": "^0.2.0", + "buffer": "^4.3.0", + "console-browserify": "^1.1.0", + "constants-browserify": "^1.0.0", + "crypto-browserify": "^3.11.0", + "domain-browser": "^1.1.1", + "events": "^3.0.0", + "https-browserify": "^1.0.0", + "os-browserify": "^0.3.0", + "path-browserify": "0.0.1", + "process": "^0.11.10", + "punycode": "^1.2.4", + "querystring-es3": "^0.2.0", + "readable-stream": "^2.3.3", + "stream-browserify": "^2.0.1", + "stream-http": "^2.7.2", + "string_decoder": "^1.0.0", + "timers-browserify": "^2.0.4", + "tty-browserify": "0.0.0", + "url": "^0.11.0", + "util": "^0.11.0", + "vm-browserify": "^1.0.1" + }, + "dependencies": { + "buffer": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", + "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", + "dev": true, + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, + "events": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.1.0.tgz", + "integrity": "sha512-Rv+u8MLHNOdMjTAFeT3nCjHn2aGlx435FP/sDHNaRhDEMwyI/aB22Kj2qIN8R0cw3z28psEQLYwxVKLsKrMgWg==", + "dev": true + }, + "stream-http": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-2.8.3.tgz", + "integrity": "sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw==", + "dev": true, + "requires": { + "builtin-status-codes": "^3.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.3.6", + "to-arraybuffer": "^1.0.0", + "xtend": "^4.0.0" + } + }, + "timers-browserify": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.11.tgz", + "integrity": "sha512-60aV6sgJ5YEbzUdn9c8kYGIqOubPoUdqQCul3SBAsRCZ40s6Y5cMcrW4dt3/k/EsbLVJNl9n6Vz3fTc+k2GeKQ==", + "dev": true, + "requires": { + "setimmediate": "^1.0.4" + } + }, + "tty-browserify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz", + "integrity": "sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY=", + "dev": true + }, + "util": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz", + "integrity": "sha512-HShAsny+zS2TZfaXxD9tYj4HQGlBezXZMZuM/S5PKLLoZkShZiGk9o5CzukI1LVHZvjdvZ2Sj1aW/Ndn2NB/HQ==", + "dev": true, + "requires": { + "inherits": "2.0.3" + }, + "dependencies": { + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true + } + } + } + } + }, "node-modules-regexp": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz", @@ -6869,13 +6599,16 @@ "remove-trailing-separator": "^1.0.1" } }, - "now-and-later": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/now-and-later/-/now-and-later-2.0.1.tgz", - "integrity": "sha512-KGvQ0cB70AQfg107Xvs/Fbu+dGmZoTRJp2TaPwcwQm3/7PteUyN2BCgk8KBMPGBUXZdVwyWS8fDCGFygBm19UQ==", + "normalize-url": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-1.9.1.tgz", + "integrity": "sha1-LMDWazHqIwNkWENuNiDYWVTGbDw=", "dev": true, "requires": { - "once": "^1.3.2" + "object-assign": "^4.0.1", + "prepend-http": "^1.0.0", + "query-string": "^4.1.0", + "sort-keys": "^1.0.0" } }, "npm-run-path": { @@ -6986,18 +6719,6 @@ "object-keys": "^1.0.11" } }, - "object.defaults": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/object.defaults/-/object.defaults-1.1.0.tgz", - "integrity": "sha1-On+GgzS0B96gbaFtiNXNKeQ1/s8=", - "dev": true, - "requires": { - "array-each": "^1.0.1", - "array-slice": "^1.0.0", - "for-own": "^1.0.0", - "isobject": "^3.0.0" - } - }, "object.getownpropertydescriptors": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.0.tgz", @@ -7008,16 +6729,6 @@ "es-abstract": "^1.17.0-next.1" } }, - "object.map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/object.map/-/object.map-1.0.1.tgz", - "integrity": "sha1-z4Plncj8wK1fQlDh94s7gb2AHTc=", - "dev": true, - "requires": { - "for-own": "^1.0.0", - "make-iterator": "^1.0.0" - } - }, "object.pick": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", @@ -7027,16 +6738,6 @@ "isobject": "^3.0.1" } }, - "object.reduce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/object.reduce/-/object.reduce-1.0.1.tgz", - "integrity": "sha1-b+NI8qx/oPlcpiEiZZkJaCW7A60=", - "dev": true, - "requires": { - "for-own": "^1.0.0", - "make-iterator": "^1.0.0" - } - }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7060,15 +6761,6 @@ "word-wrap": "~1.2.3" } }, - "ordered-read-streams": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ordered-read-streams/-/ordered-read-streams-1.0.1.tgz", - "integrity": "sha1-d8DLN8QVJdZBZtmQ/61+xqDhNj4=", - "dev": true, - "requires": { - "readable-stream": "^2.0.1" - } - }, "os-browserify": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", @@ -7106,6 +6798,12 @@ "os-tmpdir": "^1.0.0" } }, + "p-defer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", + "integrity": "sha1-n26xgvbJqozXQwBKfU+WsZaw+ww=", + "dev": true + }, "p-each-series": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-1.0.0.tgz", @@ -7121,6 +6819,12 @@ "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", "dev": true }, + "p-is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-2.1.0.tgz", + "integrity": "sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg==", + "dev": true + }, "p-limit": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.1.tgz", @@ -7139,15 +6843,6 @@ "p-limit": "^2.0.0" } }, - "p-map": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", - "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", - "dev": true, - "requires": { - "aggregate-error": "^3.0.0" - } - }, "p-reduce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-1.0.0.tgz", @@ -7166,13 +6861,15 @@ "integrity": "sha512-0DTvPVU3ed8+HNXOu5Bs+o//Mbdj9VNQMUOe9oKCwh8l0GNwpTDMKCWbRjgtD291AWnkAgkqA/LOnQS8AmS1tw==", "dev": true }, - "parents": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parents/-/parents-1.0.1.tgz", - "integrity": "sha1-/t1NK/GTp3dF/nHjcdc8MwfZx1E=", + "parallel-transform": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.2.0.tgz", + "integrity": "sha512-P2vSmIu38uIlvdcU7fDkyrxj33gTUy/ABO5ZUbGowxNCopBq/OoD42bP4UmMrJoPyk4Uqf0mu3mtWBhHCZD8yg==", "dev": true, "requires": { - "path-platform": "~0.11.15" + "cyclist": "^1.0.1", + "inherits": "^2.0.3", + "readable-stream": "^2.1.5" } }, "parse-asn1": { @@ -7189,17 +6886,6 @@ "safe-buffer": "^5.1.1" } }, - "parse-filepath": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz", - "integrity": "sha1-pjISf1Oq89FYdvWHLz/6x2PWyJE=", - "dev": true, - "requires": { - "is-absolute": "^1.0.0", - "map-cache": "^0.2.0", - "path-root": "^0.1.1" - } - }, "parse-json": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", @@ -7209,12 +6895,6 @@ "error-ex": "^1.2.0" } }, - "parse-node-version": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", - "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", - "dev": true - }, "parse-passwd": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", @@ -7257,6 +6937,12 @@ "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", "dev": true }, + "path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=", + "dev": true + }, "path-key": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", @@ -7269,27 +6955,6 @@ "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", "dev": true }, - "path-platform": { - "version": "0.11.15", - "resolved": "https://registry.npmjs.org/path-platform/-/path-platform-0.11.15.tgz", - "integrity": "sha1-6GQhf3TDaFDwhSt43Hv31KVyG/I=", - "dev": true - }, - "path-root": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz", - "integrity": "sha1-mkpoFMrBwM1zNgqV8yCDyOpHRbc=", - "dev": true, - "requires": { - "path-root-regex": "^0.1.0" - } - }, - "path-root-regex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz", - "integrity": "sha1-v8zcjfWxLcUsi0PsONGNcsBLqW0=", - "dev": true - }, "path-to-regexp": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.4.0.tgz", @@ -7334,11 +6999,6 @@ "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", "dev": true }, - "picomatch": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.1.tgz", - "integrity": "sha512-ISBaA8xQNmwELC7eOjqFKMESB2VIqt4PPDD0nsS95b/9dZXvVKOlz9keMSnoGGKcOHXfTvDD6WMaRoSc9UuhRA==" - }, "pify": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", @@ -7378,18 +7038,6 @@ "find-up": "^3.0.0" } }, - "plugin-error": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/plugin-error/-/plugin-error-1.0.1.tgz", - "integrity": "sha512-L1zP0dk7vGweZME2i+EeakvUNqSrdiI3F91TwEoYiGrAfUXmVv6fJIq4g82PAXxNsWOp0J7ZqQy/3Szz0ajTxA==", - "dev": true, - "requires": { - "ansi-colors": "^1.0.1", - "arr-diff": "^4.0.0", - "arr-union": "^3.1.0", - "extend-shallow": "^3.0.2" - } - }, "pn": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/pn/-/pn-1.1.0.tgz", @@ -7402,12 +7050,104 @@ "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=", "dev": true }, + "postcss": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.27.tgz", + "integrity": "sha512-WuQETPMcW9Uf1/22HWUWP9lgsIC+KEHg2kozMflKjbeUtw9ujvFX6QmIfozaErDkmLWS9WEnEdEe6Uo9/BNTdQ==", + "dev": true, + "requires": { + "chalk": "^2.4.2", + "source-map": "^0.6.1", + "supports-color": "^6.1.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "supports-color": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", + "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "postcss-modules-extract-imports": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-2.0.0.tgz", + "integrity": "sha512-LaYLDNS4SG8Q5WAWqIJgdHPJrDDr/Lv775rMBFUbgjTz6j34lUznACHcdRWroPvXANP2Vj7yNK57vp9eFqzLWQ==", + "dev": true, + "requires": { + "postcss": "^7.0.5" + } + }, + "postcss-modules-local-by-default": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-3.0.2.tgz", + "integrity": "sha512-jM/V8eqM4oJ/22j0gx4jrp63GSvDH6v86OqyTHHUvk4/k1vceipZsaymiZ5PvocqZOl5SFHiFJqjs3la0wnfIQ==", + "dev": true, + "requires": { + "icss-utils": "^4.1.1", + "postcss": "^7.0.16", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.0.0" + } + }, + "postcss-modules-scope": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-2.2.0.tgz", + "integrity": "sha512-YyEgsTMRpNd+HmyC7H/mh3y+MeFWevy7V1evVhJWewmMbjDHIbZbOXICC2y+m1xI1UVfIT1HMW/O04Hxyu9oXQ==", + "dev": true, + "requires": { + "postcss": "^7.0.6", + "postcss-selector-parser": "^6.0.0" + } + }, + "postcss-modules-values": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-3.0.0.tgz", + "integrity": "sha512-1//E5jCBrZ9DmRX+zCtmQtRSV6PV42Ix7Bzj9GbwJceduuf7IqP8MgeTXuRDHOWj2m0VzZD5+roFWDuU8RQjcg==", + "dev": true, + "requires": { + "icss-utils": "^4.0.0", + "postcss": "^7.0.6" + } + }, + "postcss-selector-parser": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.2.tgz", + "integrity": "sha512-36P2QR59jDTOAiIkqEprfJDsoNrvwFei3eCqKd1Y0tUsBimsq39BLp7RD+JWny3WgB1zGhJX8XVePwm9k4wdBg==", + "dev": true, + "requires": { + "cssesc": "^3.0.0", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1" + } + }, + "postcss-value-parser": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.0.3.tgz", + "integrity": "sha512-N7h4pG+Nnu5BEIzyeaaIYWs0LI5XC40OrRh5L60z0QjFsqGWcHcbkBvpe1WYpcIS9yQ8sOi/vIPt1ejQCrMVrg==", + "dev": true + }, "prelude-ls": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", "dev": true }, + "prepend-http": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz", + "integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=", + "dev": true + }, "prettier": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz", @@ -7434,12 +7174,6 @@ } } }, - "pretty-hrtime": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", - "integrity": "sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=", - "dev": true - }, "private": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/private/-/private-0.1.8.tgz", @@ -7458,6 +7192,12 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha1-mEcocL8igTL8vdhoEputEsPAKeM=", + "dev": true + }, "prompts": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.3.0.tgz", @@ -7478,6 +7218,12 @@ "react-is": "^16.8.1" } }, + "prr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", + "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY=", + "dev": true + }, "pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", @@ -7537,6 +7283,16 @@ "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", "dev": true }, + "query-string": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz", + "integrity": "sha1-u7aTucqRXCMlFbIosaArYJBD2+s=", + "dev": true, + "requires": { + "object-assign": "^4.1.0", + "strict-uri-encode": "^1.0.0" + } + }, "querystring": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", @@ -7609,15 +7365,6 @@ "react-is": "^16.9.0" } }, - "read-only-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/read-only-stream/-/read-only-stream-2.0.0.tgz", - "integrity": "sha1-JyT9aoET1zdkrCiNQ4YnDB2/F/A=", - "dev": true, - "requires": { - "readable-stream": "^2.0.2" - } - }, "read-pkg": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", @@ -7695,15 +7442,6 @@ "util.promisify": "^1.0.0" } }, - "rechoir": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", - "integrity": "sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=", - "dev": true, - "requires": { - "resolve": "^1.1.6" - } - }, "redent": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz", @@ -7832,27 +7570,6 @@ } } }, - "remove-bom-buffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/remove-bom-buffer/-/remove-bom-buffer-3.0.0.tgz", - "integrity": "sha512-8v2rWhaakv18qcvNeli2mZ/TMTL2nEyAKRvzo1WtnZBl15SHyEhrCu2/xKlJyUFKHiHgfXIyuY6g2dObJJycXQ==", - "dev": true, - "requires": { - "is-buffer": "^1.1.5", - "is-utf8": "^0.2.1" - } - }, - "remove-bom-stream": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/remove-bom-stream/-/remove-bom-stream-1.2.0.tgz", - "integrity": "sha1-BfGlk/FuQuH7kOv1nejlaVJflSM=", - "dev": true, - "requires": { - "remove-bom-buffer": "^3.0.0", - "safe-buffer": "^5.1.0", - "through2": "^2.0.3" - } - }, "remove-trailing-separator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", @@ -7880,23 +7597,6 @@ "is-finite": "^1.0.0" } }, - "replace-ext": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-1.0.0.tgz", - "integrity": "sha1-3mMSg3P8v3w8z6TeWkgMRaZ5WOs=", - "dev": true - }, - "replace-homedir": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/replace-homedir/-/replace-homedir-1.0.0.tgz", - "integrity": "sha1-6H9tUTuSjd6AgmDBK+f+xv9ueYw=", - "dev": true, - "requires": { - "homedir-polyfill": "^1.0.1", - "is-absolute": "^1.0.0", - "remove-trailing-separator": "^1.1.0" - } - }, "request": { "version": "2.88.0", "resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz", @@ -7991,15 +7691,6 @@ "integrity": "sha1-six699nWiBvItuZTM17rywoYh0g=", "dev": true }, - "resolve-options": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/resolve-options/-/resolve-options-1.1.0.tgz", - "integrity": "sha1-MrueOcBtZzONyTeMDW1gdFZq0TE=", - "dev": true, - "requires": { - "value-or-function": "^3.0.0" - } - }, "resolve-url": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", @@ -8012,20 +7703,6 @@ "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", "dev": true }, - "reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==" - }, - "rimraf": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.0.tgz", - "integrity": "sha512-NDGVxTsjqfunkds7CqsOiEnxln4Bo7Nddl3XhS4pXg5OzwkLqJ971ZVAAnB+DDLnF76N+VnDEiBHaVV8I06SUg==", - "dev": true, - "requires": { - "glob": "^7.1.3" - } - }, "ripemd160": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", @@ -8042,10 +7719,14 @@ "integrity": "sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA==", "dev": true }, - "run-parallel": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.1.9.tgz", - "integrity": "sha512-DEqnSRTDw/Tc3FXf49zedI638Z9onwUotBMiUFKmrO2sdFKIbXamXGQ3Axd4qgphxKB4kw/qP1w5kTxnfU1B9Q==" + "run-queue": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/run-queue/-/run-queue-1.0.3.tgz", + "integrity": "sha1-6Eg5bwV9Ij8kOGkkYY4laUFh7Ec=", + "dev": true, + "requires": { + "aproba": "^1.1.1" + } }, "safe-buffer": { "version": "5.1.2", @@ -8097,6 +7778,27 @@ "yargs": "^7.0.0" } }, + "sass-loader": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-8.0.2.tgz", + "integrity": "sha512-7o4dbSK8/Ol2KflEmSco4jTjQoV988bM82P9CZdmo9hR3RLnvNc0ufMNdMrB0caq38JQ/FgF4/7RcbcfKzxoFQ==", + "dev": true, + "requires": { + "clone-deep": "^4.0.1", + "loader-utils": "^1.2.3", + "neo-async": "^2.6.1", + "schema-utils": "^2.6.1", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, "sax": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", @@ -8113,6 +7815,36 @@ "object-assign": "^4.1.1" } }, + "schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-5KXuwKziQrTVHh8j/Uxz+QUbxkaLW9X/86NBlx/gnKgtsZA2GIVMUn17qWhRFwF8jdYb3Dig5hRO/W5mZqy6SQ==", + "dev": true, + "requires": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "dependencies": { + "ajv": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.0.tgz", + "integrity": "sha512-D6gFiFA0RRLyUbvijN74DWAjXSFxWKaWP7mldxkVhyhAV3+SWA9HEJPHQ2c9soIeTFJqcSdFDGFgdqs1iUU2Hw==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "fast-deep-equal": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz", + "integrity": "sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA==", + "dev": true + } + } + }, "scss-tokenizer": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz", @@ -8140,14 +7872,11 @@ "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", "dev": true }, - "semver-greatest-satisfied-range": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/semver-greatest-satisfied-range/-/semver-greatest-satisfied-range-1.1.0.tgz", - "integrity": "sha1-E+jCZYq5aRywzXEJMkAoDTb3els=", - "dev": true, - "requires": { - "sver-compat": "^1.5.0" - } + "serialize-javascript": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-2.1.2.tgz", + "integrity": "sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ==", + "dev": true }, "set-blocking": { "version": "2.0.0", @@ -8178,6 +7907,12 @@ } } }, + "setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=", + "dev": true + }, "sha.js": { "version": "2.4.11", "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", @@ -8188,23 +7923,13 @@ "safe-buffer": "^5.0.1" } }, - "shasum": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/shasum/-/shasum-1.0.2.tgz", - "integrity": "sha1-5wEjENj0F/TetXEhUOVni4euVl8=", + "shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", "dev": true, "requires": { - "json-stable-stringify": "~0.0.0", - "sha.js": "~2.4.4" - } - }, - "shasum-object": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shasum-object/-/shasum-object-1.0.0.tgz", - "integrity": "sha512-Iqo5rp/3xVi6M4YheapzZhhGPVs0yZwHj7wvwQ1B9z8H6zk+FEnI7y3Teq7qwnekfEhu8WmG2z0z4iWZaxLWVg==", - "dev": true, - "requires": { - "fast-safe-stringify": "^2.0.7" + "kind-of": "^6.0.2" } }, "shebang-command": { @@ -8222,12 +7947,6 @@ "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", "dev": true }, - "shell-quote": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.2.tgz", - "integrity": "sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg==", - "dev": true - }, "shellwords": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", @@ -8240,23 +7959,12 @@ "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", "dev": true }, - "simple-concat": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.0.tgz", - "integrity": "sha1-c0TLuLbib7J9ZrL8hvn21Zl1IcY=", - "dev": true - }, "sisteransi": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.4.tgz", "integrity": "sha512-/ekMoM4NJ59ivGSfKapeG+FWtrmWvA1p6FBZwXrqojw90vJu8lBmrTxCMuBCydKtkaUe2zt4PlxeTKpjwMbyig==", "dev": true }, - "slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==" - }, "snapdragon": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", @@ -8364,6 +8072,21 @@ } } }, + "sort-keys": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz", + "integrity": "sha1-RBttTTRnmPG05J6JIK37oOVD+a0=", + "dev": true, + "requires": { + "is-plain-obj": "^1.0.0" + } + }, + "source-list-map": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", + "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==", + "dev": true + }, "source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -8407,12 +8130,6 @@ "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=", "dev": true }, - "sparkles": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/sparkles/-/sparkles-1.0.1.tgz", - "integrity": "sha512-dSO0DDYUahUt/0/pD/Is3VIm5TGJjludZ0HVymmhYF6eNA53PVLhnUk0znSYbH8IYBuJdCE+1luR22jNLMaQdw==", - "dev": true - }, "spdx-correct": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.0.tgz", @@ -8471,11 +8188,14 @@ "tweetnacl": "~0.14.0" } }, - "stack-trace": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", - "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=", - "dev": true + "ssri": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.1.tgz", + "integrity": "sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA==", + "dev": true, + "requires": { + "figgy-pudding": "^3.5.1" + } }, "stack-utils": { "version": "1.0.2", @@ -8529,45 +8249,14 @@ "readable-stream": "^2.0.2" } }, - "stream-combiner2": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", - "integrity": "sha1-+02KFCDqNidk4hrUeAOXvry0HL4=", + "stream-each": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/stream-each/-/stream-each-1.2.3.tgz", + "integrity": "sha512-vlMC2f8I2u/bZGqkdfLQW/13Zihpej/7PmSiMQsbYddxuTsJp8vRe2x2FvVExZg7FaOds43ROAuFJwPR4MTZLw==", "dev": true, "requires": { - "duplexer2": "~0.1.0", - "readable-stream": "^2.0.2" - } - }, - "stream-exhaust": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/stream-exhaust/-/stream-exhaust-1.0.2.tgz", - "integrity": "sha512-b/qaq/GlBK5xaq1yrK9/zFcyRSTNxmcZwFLGSTG0mXgZl/4Z6GgiyYOXOvY7N3eEvFRAG1bkDRz5EPGSvPYQlw==", - "dev": true - }, - "stream-http": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-3.1.0.tgz", - "integrity": "sha512-cuB6RgO7BqC4FBYzmnvhob5Do3wIdIsXAgGycHJnW+981gHqoYcYz9lqjJrk8WXRddbwPuqPYRl+bag6mYv4lw==", - "dev": true, - "requires": { - "builtin-status-codes": "^3.0.0", - "inherits": "^2.0.1", - "readable-stream": "^3.0.6", - "xtend": "^4.0.0" - }, - "dependencies": { - "readable-stream": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz", - "integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==", - "dev": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - } + "end-of-stream": "^1.1.0", + "stream-shift": "^1.0.0" } }, "stream-shift": { @@ -8576,15 +8265,11 @@ "integrity": "sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI=", "dev": true }, - "stream-splicer": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/stream-splicer/-/stream-splicer-2.0.1.tgz", - "integrity": "sha512-Xizh4/NPuYSyAXyT7g8IvdJ9HJpxIGL9PjyhtywCZvvP0OPIdqyrr4dMikeuvY8xahpdKEBlBTySe583totajg==", - "dev": true, - "requires": { - "inherits": "^2.0.1", - "readable-stream": "^2.0.2" - } + "strict-uri-encode": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", + "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=", + "dev": true }, "string-length": { "version": "2.0.0", @@ -8697,13 +8382,14 @@ "get-stdin": "^4.0.1" } }, - "subarg": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/subarg/-/subarg-1.0.0.tgz", - "integrity": "sha1-9izxdYHplrSPyWVpn1TAauJouNI=", + "style-loader": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-1.1.3.tgz", + "integrity": "sha512-rlkH7X/22yuwFYK357fMN/BxYOorfnfq0eD7+vqlemSK4wEcejFF1dg4zxP0euBW8NrYx2WZzZ8PPFevr7D+Kw==", "dev": true, "requires": { - "minimist": "^1.1.0" + "loader-utils": "^1.2.3", + "schema-utils": "^2.6.4" } }, "supports-color": { @@ -8715,16 +8401,6 @@ "has-flag": "^3.0.0" } }, - "sver-compat": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/sver-compat/-/sver-compat-1.5.0.tgz", - "integrity": "sha1-PPh9/rTQe0o/FIJ7wYaz/QxkXNg=", - "dev": true, - "requires": { - "es6-iterator": "^2.0.1", - "es6-symbol": "^3.1.1" - } - }, "symbol-observable": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", @@ -8736,14 +8412,11 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, - "syntax-error": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/syntax-error/-/syntax-error-1.4.0.tgz", - "integrity": "sha512-YPPlu67mdnHGTup2A8ff7BC2Pjq0e0Yp/IyTFN03zWO0RcK07uLcbi7C2KpGR2FvWbaB0+bfE27a+sBKebSo7w==", - "dev": true, - "requires": { - "acorn-node": "^1.2.0" - } + "tapable": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", + "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==", + "dev": true }, "tar": { "version": "2.2.2", @@ -8756,6 +8429,61 @@ "inherits": "2" } }, + "terser": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/terser/-/terser-4.6.7.tgz", + "integrity": "sha512-fmr7M1f7DBly5cX2+rFDvmGBAaaZyPrHYK4mMdHEDAdNTqXSZgSOfqsfGq2HqPGT/1V0foZZuCZFx8CHKgAk3g==", + "dev": true, + "requires": { + "commander": "^2.20.0", + "source-map": "~0.6.1", + "source-map-support": "~0.5.12" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "terser-webpack-plugin": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.3.tgz", + "integrity": "sha512-QMxecFz/gHQwteWwSo5nTc6UaICqN1bMedC5sMtUc7y3Ha3Q8y6ZO0iCR8pq4RJC8Hjf0FEPEHZqcMB/+DFCrA==", + "dev": true, + "requires": { + "cacache": "^12.0.2", + "find-cache-dir": "^2.1.0", + "is-wsl": "^1.1.0", + "schema-utils": "^1.0.0", + "serialize-javascript": "^2.1.2", + "source-map": "^0.6.1", + "terser": "^4.1.2", + "webpack-sources": "^1.4.0", + "worker-farm": "^1.7.0" + }, + "dependencies": { + "schema-utils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz", + "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==", + "dev": true, + "requires": { + "ajv": "^6.1.0", + "ajv-errors": "^1.0.0", + "ajv-keywords": "^3.1.0" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, "test-exclude": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-5.2.3.tgz", @@ -8846,12 +8574,6 @@ "integrity": "sha1-iQN8vJLFarGJJua6TLsgDhVnKmo=", "dev": true }, - "through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", - "dev": true - }, "through2": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", @@ -8862,46 +8584,17 @@ "xtend": "~4.0.1" } }, - "through2-filter": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/through2-filter/-/through2-filter-3.0.0.tgz", - "integrity": "sha512-jaRjI2WxN3W1V8/FMZ9HKIBXixtiqs3SQSX4/YGIiP3gL6djW48VoZq9tDqeCWs3MT8YY5wb/zli8VW8snY1CA==", - "dev": true, - "requires": { - "through2": "~2.0.0", - "xtend": "~4.0.0" - } - }, - "time-stamp": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/time-stamp/-/time-stamp-1.1.0.tgz", - "integrity": "sha1-dkpaEa9QVhkhsTPztE5hhofg9cM=", - "dev": true - }, - "timers-browserify": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-1.4.2.tgz", - "integrity": "sha1-ycWLV1voQHN1y14kYtrO50NZ9B0=", - "dev": true, - "requires": { - "process": "~0.11.0" - } - }, "tmpl": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz", "integrity": "sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE=", "dev": true }, - "to-absolute-glob": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz", - "integrity": "sha1-GGX0PZ50sIItufFFt4z/fQ98hJs=", - "dev": true, - "requires": { - "is-absolute": "^1.0.0", - "is-negated-glob": "^1.0.0" - } + "to-arraybuffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", + "integrity": "sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M=", + "dev": true }, "to-fast-properties": { "version": "2.0.0", @@ -8951,15 +8644,6 @@ "repeat-string": "^1.6.1" } }, - "to-through": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-through/-/to-through-2.0.0.tgz", - "integrity": "sha1-/JKtq6ByZHvAtn1rA2ZKoZUJOvY=", - "dev": true, - "requires": { - "through2": "^2.0.3" - } - }, "tough-cookie": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz", @@ -9002,10 +8686,10 @@ "glob": "^7.1.2" } }, - "tty-browserify": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.1.tgz", - "integrity": "sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==", + "tslib": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.11.1.tgz", + "integrity": "sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA==", "dev": true }, "tunnel-agent": { @@ -9023,12 +8707,6 @@ "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", "dev": true }, - "type": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/type/-/type-1.0.1.tgz", - "integrity": "sha512-MAM5dBMJCJNKs9E7JXo4CXRAansRfG0nlJxW7Wf6GZzSOvH31zClSaHdIMWLehe/EGMBkqeC55rrkaOr5Oo7Nw==", - "dev": true - }, "type-check": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", @@ -9044,54 +8722,6 @@ "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", "dev": true }, - "umd": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/umd/-/umd-3.0.3.tgz", - "integrity": "sha512-4IcGSufhFshvLNcMCV80UnQVlZ5pMOC8mvNPForqwA4+lzYQuetTESLDQkeLmihq8bRcnpbQa48Wb8Lh16/xow==", - "dev": true - }, - "unc-path-regex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", - "integrity": "sha1-5z3T17DXxe2G+6xrCufYxqadUPo=", - "dev": true - }, - "undeclared-identifiers": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/undeclared-identifiers/-/undeclared-identifiers-1.1.3.tgz", - "integrity": "sha512-pJOW4nxjlmfwKApE4zvxLScM/njmwj/DiUBv7EabwE4O8kRUy+HIwxQtZLBPll/jx1LJyBcqNfB3/cpv9EZwOw==", - "dev": true, - "requires": { - "acorn-node": "^1.3.0", - "dash-ast": "^1.0.0", - "get-assigned-identifiers": "^1.2.0", - "simple-concat": "^1.0.0", - "xtend": "^4.0.1" - } - }, - "undertaker": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/undertaker/-/undertaker-1.2.1.tgz", - "integrity": "sha512-71WxIzDkgYk9ZS+spIB8iZXchFhAdEo2YU8xYqBYJ39DIUIqziK78ftm26eecoIY49X0J2MLhG4hr18Yp6/CMA==", - "dev": true, - "requires": { - "arr-flatten": "^1.0.1", - "arr-map": "^2.0.0", - "bach": "^1.0.0", - "collection-map": "^1.0.0", - "es6-weak-map": "^2.0.1", - "last-run": "^1.1.0", - "object.defaults": "^1.0.0", - "object.reduce": "^1.0.0", - "undertaker-registry": "^1.0.0" - } - }, - "undertaker-registry": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/undertaker-registry/-/undertaker-registry-1.0.1.tgz", - "integrity": "sha1-XkvaMI5KiirlhPm5pDWaSZglzFA=", - "dev": true - }, "unicode-canonical-property-names-ecmascript": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", @@ -9132,14 +8762,28 @@ "set-value": "^2.0.1" } }, - "unique-stream": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/unique-stream/-/unique-stream-2.3.1.tgz", - "integrity": "sha512-2nY4TnBE70yoxHkDli7DMazpWiP7xMdCYqU2nBRO0UB+ZpEkGsSija7MvmvnZFUeC+mrgiUfcHSr3LmRFIg4+A==", + "uniq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz", + "integrity": "sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8=", + "dev": true + }, + "unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", "dev": true, "requires": { - "json-stable-stringify-without-jsonify": "^1.0.1", - "through2-filter": "^3.0.0" + "unique-slug": "^2.0.0" + } + }, + "unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "dev": true, + "requires": { + "imurmurhash": "^0.1.4" } }, "unset-value": { @@ -9235,23 +8879,6 @@ "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", "dev": true }, - "util": { - "version": "0.10.4", - "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", - "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", - "dev": true, - "requires": { - "inherits": "2.0.3" - }, - "dependencies": { - "inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", - "dev": true - } - } - }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -9274,14 +8901,11 @@ "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==", "dev": true }, - "v8flags": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-3.1.3.tgz", - "integrity": "sha512-amh9CCg3ZxkzQ48Mhcb8iX7xpAfYJgePHxWMQCBWECpOSqJUXgY26ncA61UTV0BkPqfhcy6mzwCIoP4ygxpW8w==", - "dev": true, - "requires": { - "homedir-polyfill": "^1.0.1" - } + "v8-compile-cache": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.0.3.tgz", + "integrity": "sha512-CNmdbwQMBjwr9Gsmohvm0pbL954tJrNzf6gWL3K+QMQf00PF7ERGrEiLgjuU3mKreLC2MeGhUsNV9ybTbLgd3w==", + "dev": true }, "validate-npm-package-license": { "version": "3.0.4", @@ -9293,12 +8917,6 @@ "spdx-expression-parse": "^3.0.0" } }, - "value-or-function": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/value-or-function/-/value-or-function-3.0.0.tgz", - "integrity": "sha1-HCQ6ULWVwb5Up1S/7OhWO5/42BM=", - "dev": true - }, "verror": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", @@ -9310,89 +8928,6 @@ "extsprintf": "^1.2.0" } }, - "vinyl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-2.2.0.tgz", - "integrity": "sha512-MBH+yP0kC/GQ5GwBqrTPTzEfiiLjta7hTtvQtbxBgTeSXsmKQRQecjibMbxIXzVT3Y9KJK+drOz1/k+vsu8Nkg==", - "dev": true, - "requires": { - "clone": "^2.1.1", - "clone-buffer": "^1.0.0", - "clone-stats": "^1.0.0", - "cloneable-readable": "^1.0.0", - "remove-trailing-separator": "^1.0.1", - "replace-ext": "^1.0.0" - } - }, - "vinyl-buffer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/vinyl-buffer/-/vinyl-buffer-1.0.1.tgz", - "integrity": "sha1-lsGjR5uMU5JULGEgKQE7Wyf4i78=", - "dev": true, - "requires": { - "bl": "^1.2.1", - "through2": "^2.0.3" - } - }, - "vinyl-fs": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-3.0.3.tgz", - "integrity": "sha512-vIu34EkyNyJxmP0jscNzWBSygh7VWhqun6RmqVfXePrOwi9lhvRs//dOaGOTRUQr4tx7/zd26Tk5WeSVZitgng==", - "dev": true, - "requires": { - "fs-mkdirp-stream": "^1.0.0", - "glob-stream": "^6.1.0", - "graceful-fs": "^4.0.0", - "is-valid-glob": "^1.0.0", - "lazystream": "^1.0.0", - "lead": "^1.0.0", - "object.assign": "^4.0.4", - "pumpify": "^1.3.5", - "readable-stream": "^2.3.3", - "remove-bom-buffer": "^3.0.0", - "remove-bom-stream": "^1.2.0", - "resolve-options": "^1.1.0", - "through2": "^2.0.0", - "to-through": "^2.0.0", - "value-or-function": "^3.0.0", - "vinyl": "^2.0.0", - "vinyl-sourcemap": "^1.1.0" - } - }, - "vinyl-source-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/vinyl-source-stream/-/vinyl-source-stream-2.0.0.tgz", - "integrity": "sha1-84pa+53R6Ttl1VBGmsYYKsT1S44=", - "dev": true, - "requires": { - "through2": "^2.0.3", - "vinyl": "^2.1.0" - } - }, - "vinyl-sourcemap": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/vinyl-sourcemap/-/vinyl-sourcemap-1.1.0.tgz", - "integrity": "sha1-kqgAWTo4cDqM2xHYswCtS+Y7PhY=", - "dev": true, - "requires": { - "append-buffer": "^1.0.2", - "convert-source-map": "^1.5.0", - "graceful-fs": "^4.1.6", - "normalize-path": "^2.1.1", - "now-and-later": "^2.0.0", - "remove-bom-buffer": "^3.0.0", - "vinyl": "^2.0.0" - } - }, - "vinyl-sourcemaps-apply": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/vinyl-sourcemaps-apply/-/vinyl-sourcemaps-apply-0.2.1.tgz", - "integrity": "sha1-q2VJ1h0XLCsbh75cUI0jnI74dwU=", - "dev": true, - "requires": { - "source-map": "^0.5.1" - } - }, "vm-browserify": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", @@ -9417,12 +8952,333 @@ "makeerror": "1.0.x" } }, + "watchpack": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.6.0.tgz", + "integrity": "sha512-i6dHe3EyLjMmDlU1/bGQpEw25XSjkJULPuAVKCbNRefQVq48yXKUpwg538F7AZTf9kyr57zj++pQFltUa5H7yA==", + "dev": true, + "requires": { + "chokidar": "^2.0.2", + "graceful-fs": "^4.1.2", + "neo-async": "^2.5.0" + } + }, "webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", "dev": true }, + "webpack": { + "version": "4.42.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.42.1.tgz", + "integrity": "sha512-SGfYMigqEfdGchGhFFJ9KyRpQKnipvEvjc1TwrXEPCM6H5Wywu10ka8o3KGrMzSMxMQKt8aCHUFh5DaQ9UmyRg==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.9.0", + "@webassemblyjs/helper-module-context": "1.9.0", + "@webassemblyjs/wasm-edit": "1.9.0", + "@webassemblyjs/wasm-parser": "1.9.0", + "acorn": "^6.2.1", + "ajv": "^6.10.2", + "ajv-keywords": "^3.4.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^4.1.0", + "eslint-scope": "^4.0.3", + "json-parse-better-errors": "^1.0.2", + "loader-runner": "^2.4.0", + "loader-utils": "^1.2.3", + "memory-fs": "^0.4.1", + "micromatch": "^3.1.10", + "mkdirp": "^0.5.3", + "neo-async": "^2.6.1", + "node-libs-browser": "^2.2.1", + "schema-utils": "^1.0.0", + "tapable": "^1.1.3", + "terser-webpack-plugin": "^1.4.3", + "watchpack": "^1.6.0", + "webpack-sources": "^1.4.1" + }, + "dependencies": { + "acorn": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.1.tgz", + "integrity": "sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA==", + "dev": true + }, + "schema-utils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz", + "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==", + "dev": true, + "requires": { + "ajv": "^6.1.0", + "ajv-errors": "^1.0.0", + "ajv-keywords": "^3.1.0" + } + } + } + }, + "webpack-cli": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-3.3.11.tgz", + "integrity": "sha512-dXlfuml7xvAFwYUPsrtQAA9e4DOe58gnzSxhgrO/ZM/gyXTBowrsYeubyN4mqGhYdpXMFNyQ6emjJS9M7OBd4g==", + "dev": true, + "requires": { + "chalk": "2.4.2", + "cross-spawn": "6.0.5", + "enhanced-resolve": "4.1.0", + "findup-sync": "3.0.0", + "global-modules": "2.0.0", + "import-local": "2.0.0", + "interpret": "1.2.0", + "loader-utils": "1.2.3", + "supports-color": "6.1.0", + "v8-compile-cache": "2.0.3", + "yargs": "13.2.4" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "dev": true, + "requires": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + } + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "emojis-list": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-2.1.0.tgz", + "integrity": "sha1-TapNnbAPmBmIDHn6RXrlsJof04k=", + "dev": true + }, + "enhanced-resolve": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.1.0.tgz", + "integrity": "sha512-F/7vkyTtyc/llOIn8oWclcB25KdRaiPBpZYDgJHgh/UHtpgT2p2eldQgtQnLtUvfMKPKxbRaQM/hHkvLHt1Vng==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "memory-fs": "^0.4.0", + "tapable": "^1.0.0" + } + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true + }, + "global-modules": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", + "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", + "dev": true, + "requires": { + "global-prefix": "^3.0.0" + } + }, + "global-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", + "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "dev": true, + "requires": { + "ini": "^1.3.5", + "kind-of": "^6.0.2", + "which": "^1.3.1" + } + }, + "invert-kv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-2.0.0.tgz", + "integrity": "sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA==", + "dev": true + }, + "json5": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "lcid": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-2.0.0.tgz", + "integrity": "sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA==", + "dev": true, + "requires": { + "invert-kv": "^2.0.0" + } + }, + "loader-utils": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.2.3.tgz", + "integrity": "sha512-fkpz8ejdnEMG3s37wGL07iSBDg99O9D5yflE9RGNH3hRdx9SOwYfnGYdZOUIZitN8E+E2vkq3MUMYMvPYl5ZZA==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^2.0.0", + "json5": "^1.0.1" + } + }, + "os-locale": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-3.1.0.tgz", + "integrity": "sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q==", + "dev": true, + "requires": { + "execa": "^1.0.0", + "lcid": "^2.0.0", + "mem": "^4.0.0" + } + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + }, + "supports-color": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", + "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", + "dev": true + }, + "wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + } + }, + "y18n": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", + "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", + "dev": true + }, + "yargs": { + "version": "13.2.4", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.2.4.tgz", + "integrity": "sha512-HG/DWAJa1PAnHT9JAhNa8AbAv3FPaiLzioSjCcmuXXhP8MlpHO5vwls4g4j6n30Z74GVQj8Xa62dWVx1QCGklg==", + "dev": true, + "requires": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "os-locale": "^3.1.0", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.0" + } + }, + "yargs-parser": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", + "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + }, + "webpack-merge": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-4.2.2.tgz", + "integrity": "sha512-TUE1UGoTX2Cd42j3krGYqObZbOD+xF7u28WB7tfUordytSjbWTIjK/8V0amkBfTYN4/pB/GIDlJZZ657BGG19g==", + "dev": true, + "requires": { + "lodash": "^4.17.15" + } + }, + "webpack-sources": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz", + "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==", + "dev": true, + "requires": { + "source-list-map": "^2.0.0", + "source-map": "~0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, "whatwg-encoding": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", @@ -9479,6 +9335,15 @@ "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", "dev": true }, + "worker-farm": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/worker-farm/-/worker-farm-1.7.0.tgz", + "integrity": "sha512-rvw3QTZc8lAxyVrqcSGVm5yP/IJ2UcB3U0graE3LCFoZ0Yn2x4EoVSqJKdB/T5M+FLcRPjz4TDacRf3OCfNUzw==", + "dev": true, + "requires": { + "errno": "~0.1.7" + } + }, "wrap-ansi": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", diff --git a/package.json b/package.json index 48d1206..06fb422 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "scripts": { "lint": "npx prettier \"src/newsreader/js/**/*.js\" --check", "format": "npx prettier \"src/newsreader/js/**/*.js\" --write", - "watch": "npx gulp watch", + "build": "webpack --config webpack.dev.babel.js", + "build:prod": "webpack --config webpack.prod.babel.js", + "watch": "npx webpack --config webpack.dev.babel.js --watch", "test": "npx jest", "test:watch": "npm test -- --watch" }, @@ -17,7 +19,6 @@ "author": "Sonny", "license": "GPL-3.0-or-later", "dependencies": { - "globby": "^11.0.0", "js-cookie": "^2.2.1", "lodash": "^4.17.15", "object-assign": "^4.1.1", @@ -38,23 +39,22 @@ "@babel/register": "^7.7.7", "@babel/runtime": "^7.7.7", "babel-jest": "^24.9.0", - "babelify": "^10.0.0", - "browserify": "^16.5.0", - "del": "^5.1.0", + "babel-loader": "^8.1.0", + "clean-webpack-plugin": "^3.0.0", + "css-loader": "^3.4.2", "fetch-mock": "^8.3.1", - "gulp": "^4.0.2", - "gulp-babel": "^8.0.0", - "gulp-cli": "^2.2.0", - "gulp-concat": "^2.6.1", - "gulp-sass": "^4.0.2", "jest": "^24.9.0", + "mini-css-extract-plugin": "^0.9.0", "node-fetch": "^2.6.0", "node-sass": "^4.13.1", "prettier": "^1.19.1", "react": "^16.12.0", "react-dom": "^16.12.0", "redux-mock-store": "^1.5.4", - "vinyl-buffer": "^1.0.1", - "vinyl-source-stream": "^2.0.0" + "sass-loader": "^8.0.2", + "style-loader": "^1.1.3", + "webpack": "^4.42.1", + "webpack-cli": "^3.3.11", + "webpack-merge": "^4.2.2" } } diff --git a/src/newsreader/js/pages/categories/index.js b/src/newsreader/js/pages/categories/index.js index 7c915fa..9d75bb9 100644 --- a/src/newsreader/js/pages/categories/index.js +++ b/src/newsreader/js/pages/categories/index.js @@ -3,8 +3,11 @@ import ReactDOM from 'react-dom'; import App from './App.js'; -const content = document.getElementById('categories--page'); -const dataScript = document.getElementById('categories-data'); -const categories = JSON.parse(dataScript.textContent); +const page = document.getElementById('categories--page'); -ReactDOM.render(, content); +if (page) { + const dataScript = document.getElementById('categories-data'); + const categories = JSON.parse(dataScript.textContent); + + ReactDOM.render(, page); +} diff --git a/src/newsreader/js/pages/homepage/components/PostModal.js b/src/newsreader/js/pages/homepage/components/PostModal.js index c540982..4abf710 100644 --- a/src/newsreader/js/pages/homepage/components/PostModal.js +++ b/src/newsreader/js/pages/homepage/components/PostModal.js @@ -51,7 +51,7 @@ class PostModal extends React.Component { className="button post__close-button" onClick={() => this.props.unSelectPost()} > - Close + Close

      {`${post.title} `}

      diff --git a/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js b/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js index 2abcc63..0477fa7 100644 --- a/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js +++ b/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js @@ -52,7 +52,7 @@ class FeedList extends React.Component { return (
      - +

      Select an item to show its unread posts

      diff --git a/src/newsreader/js/pages/homepage/index.js b/src/newsreader/js/pages/homepage/index.js index d5e31f0..c16ed39 100644 --- a/src/newsreader/js/pages/homepage/index.js +++ b/src/newsreader/js/pages/homepage/index.js @@ -6,12 +6,15 @@ import configureStore from './configureStore.js'; import App from './App.js'; -const content = document.getElementById('homepage--page'); -const store = configureStore(); +const page = document.getElementById('homepage--page'); -ReactDOM.render( - - - , - content -); +if (page) { + const store = configureStore(); + + ReactDOM.render( + + + , + page + ); +} diff --git a/src/newsreader/js/pages/rules/index.js b/src/newsreader/js/pages/rules/index.js index 0402bab..d0b46e9 100644 --- a/src/newsreader/js/pages/rules/index.js +++ b/src/newsreader/js/pages/rules/index.js @@ -3,8 +3,11 @@ import ReactDOM from 'react-dom'; import App from './App.js'; -const content = document.getElementById('rules--page'); -const dataScript = document.getElementById('rules-data'); -const rules = JSON.parse(dataScript.textContent); +const page = document.getElementById('rules--page'); -ReactDOM.render(, content); +if (page) { + const dataScript = document.getElementById('rules-data'); + const rules = JSON.parse(dataScript.textContent); + + ReactDOM.render(, page); +} diff --git a/src/newsreader/news/collection/templates/collection/rules.html b/src/newsreader/news/collection/templates/collection/rules.html index dbe2f0c..508916a 100644 --- a/src/newsreader/news/collection/templates/collection/rules.html +++ b/src/newsreader/news/collection/templates/collection/rules.html @@ -25,5 +25,6 @@ {% endfor %} ] - + + {{ block.super }} {% endblock %} diff --git a/src/newsreader/news/core/templates/core/categories.html b/src/newsreader/news/core/templates/core/categories.html index f9cb57f..be4a449 100644 --- a/src/newsreader/news/core/templates/core/categories.html +++ b/src/newsreader/news/core/templates/core/categories.html @@ -30,5 +30,6 @@ {% endfor %} ] - + + {{ block.super }} {% endblock %} diff --git a/src/newsreader/news/core/templates/core/homepage.html b/src/newsreader/news/core/templates/core/homepage.html index bddb5a3..8904517 100644 --- a/src/newsreader/news/core/templates/core/homepage.html +++ b/src/newsreader/news/core/templates/core/homepage.html @@ -5,7 +5,3 @@ {% block content %}
      {% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/src/newsreader/static/icons/rss.png b/src/newsreader/static/icons/rss.png new file mode 100644 index 0000000000000000000000000000000000000000..ea8855cb06e91f3e451fc597f2211c4ba1917e84 GIT binary patch literal 1913 zcmV-<2Zs2GP) zaB^>EX>4U6ba`-PAZ2)IW&i+q+QnB}lIti8{nsjH2}mFW%fURV?ha=8bFfJ|=}vc? z&Qx7fJS>DH&Jo0+{_meb|KJhMj)Gc>IYx&kA^Ybt&$l ztQy;{c{MWM=~fU7C^dy6FVa1Gx3}qtz#p}sdbHwo>S4b4;1i)^f7vSlx!c|qKz9q! z9mtmveMbK}`UJj|CHJ4Oj0+{pQ_Y{^?f_rPqTk)*4^kBj>JD?l28-Jc6Pxl!Zuy;O4?RVH zoC+pu%K#vPZ$&Wd!GMcMc#iB$#F&9&0zn0lix0Aa0alfUASHMog-nu6-&kY3>xxR0 zDaPFdBGgB3;3oS5vO-YgkA;LBD(W?;YE;uinzck9L(~|f#d*mU^A=1knpv`JCHWMR zrkE_HlvBx)J?=owFXj1ooFh`*7D{l{2Yx8%m?u;F$aAN8_P9JoNBtmr#&Aa_uhJ3C2pp4om zj8dL1Xucl{nqa5;_aizJ;optuQH1mi#Fyy54e_@T(ph?6!swa7pCYGs6Y(vKK0ENW z$zrYIu;bG4yXJ;l;^myA6F8nClAgeE)}W`8cs?~qFXLL{GT7ZH_*~oH5>rwRIzO~O z_1yEA1GS!m)$*O*r6=y{*8&aJ@D0-D3@-KXZ0(&dg0tKsF9Ci?@a6xTAn6B>9|lf1 zq8~iI6e&Fjm4CCEXhcV7|rZ4Fm6i{Vhh%PTPt8cZTq{t7Y7Z{{bmR3m#FmhEo6l0flKpLr_UWLm+T+ zZ)Rz1WdHzpoPCi!NW(xJ#a~-Rk&1&ER2%|UCkvt?f{Rw62o*xD(5i#UrC-pbAxUv@ z6kH1qek@iUT%2`va1{i>4-h9uCq)-2@qbC7MT`f>{djlparX`g)=EsX+QtA)x6Ncc zCZ;p1V#g~&=!bwI%*f0#<|HWr-|=;i0AKH-Jj?&wpQBsNS_}w?#IwvWZQ>2$sZHD9 zyiY8!f~*ps6OWp7LE=ZQD;~db&bu7snSz;0&Jzp7V!nly7G?!gBc3FVsG3gs!L-LJ z=Pk}!slpof#ebm(+40$T_tXSgF|2> zPuc4}@9u2v?cXzv{(b<=xpK>D9qEn$000JJOGiWi{{a60|De66lK=n!32;bRa{vGq zApigtApy3pqv8Mn00(qQO+^Rf0~r)B5rt`tTmS$8n@L1LR5;6}(?N(&Q547V&-*th zO;=ec@kWxcAvTO+VPldF-KMN8l(EtzJ9|n=QEEmkY*4R;#Wb=}NR*TkQmAl z{@L(s?w{A+-=yYj?(Lj&zxSNqy(g>d+G4B^sv^IRt@w!NmsJ7%a)zMvlJVuiu_?d6`39;YUlrnX8?TdZ z*cAS3!U4N4yMTN1a00000NkvXXu0mjfIjfc+ literal 0 HcmV?d00001 diff --git a/src/newsreader/templates/base.html b/src/newsreader/templates/base.html index 746ef54..7f3f560 100644 --- a/src/newsreader/templates/base.html +++ b/src/newsreader/templates/base.html @@ -4,6 +4,7 @@ Newreader + {% block head %} {% endblock %} @@ -38,5 +39,7 @@ {% block content %}{% endblock content %} - {% block scripts %}{% endblock %} + {% block scripts %} + + {% endblock %} diff --git a/webpack.common.babel.js b/webpack.common.babel.js new file mode 100644 index 0000000..0a0602e --- /dev/null +++ b/webpack.common.babel.js @@ -0,0 +1,33 @@ +import { resolve } from 'path'; +import { CleanWebpackPlugin } from 'clean-webpack-plugin'; +import MiniCssExtractPlugin from 'mini-css-extract-plugin'; + +export default { + entry: { + main: ['./src/newsreader/js/index.js', './src/newsreader/scss/index.scss'], + }, + output: { + path: resolve(__dirname, 'src', 'newsreader', 'static', 'js'), + filename: '[name].bundle.js', + }, + module: { + rules: [ + { + test: /\.(js|jsx)$/, + exclude: /(node_modules|bower_components)/, + use: { loader: 'babel-loader' }, + }, + { + test: /\.(sass|scss)$/, + use: [{ loader: MiniCssExtractPlugin.loader }, 'css-loader', 'sass-loader'], + }, + ], + }, + plugins: [ + new MiniCssExtractPlugin({ + filename: './src/newsreader/static/css/[name].css', + allChunks: true, + }), + new CleanWebpackPlugin(), + ], +}; diff --git a/webpack.dev.babel.js b/webpack.dev.babel.js new file mode 100644 index 0000000..32c7fd0 --- /dev/null +++ b/webpack.dev.babel.js @@ -0,0 +1,7 @@ +import merge from 'webpack-merge'; +import common from './webpack.common.babel.js'; + +export default merge(common, { + mode: 'development', + devtool: 'inline-source-map', +}); diff --git a/webpack.prod.babel.js b/webpack.prod.babel.js new file mode 100644 index 0000000..6f1c942 --- /dev/null +++ b/webpack.prod.babel.js @@ -0,0 +1,4 @@ +import merge from 'webpack-merge'; +import common from './webpack.common.babel.js'; + +export default merge(common, { mode: 'production' }); From 73ae0271e40839eb4c47f0c5e87c0ea68c50ff90 Mon Sep 17 00:00:00 2001 From: Sonny Date: Wed, 25 Mar 2020 22:38:56 +0100 Subject: [PATCH 072/422] Update npm commands --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 06fb422..03d911f 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,8 @@ "scripts": { "lint": "npx prettier \"src/newsreader/js/**/*.js\" --check", "format": "npx prettier \"src/newsreader/js/**/*.js\" --write", - "build": "webpack --config webpack.dev.babel.js", - "build:prod": "webpack --config webpack.prod.babel.js", + "build": "npx webpack --config webpack.dev.babel.js", + "build:prod": "npx webpack --config webpack.prod.babel.js", "watch": "npx webpack --config webpack.dev.babel.js --watch", "test": "npx jest", "test:watch": "npm test -- --watch" From ae942f5ef944950e89ce87fe8e97b5a7363f0a9c Mon Sep 17 00:00:00 2001 From: sonny Date: Sat, 11 Apr 2020 09:49:07 +0200 Subject: [PATCH 073/422] Fix category action test This was the same test as before -.- --- package-lock.json | 5 + package.json | 3 +- src/newsreader/js/components/Messages.js | 34 ++++-- .../categories/components/CategoryCard.js | 10 +- src/newsreader/js/pages/homepage/App.js | 16 ++- .../js/pages/homepage/actions/categories.js | 12 ++- .../js/pages/homepage/actions/error.js | 6 ++ .../js/pages/homepage/actions/posts.js | 12 ++- .../js/pages/homepage/actions/rules.js | 11 +- .../js/pages/homepage/actions/selected.js | 9 ++ .../homepage/components/feedlist/FeedList.js | 16 ++- .../homepage/components/sidebar/RuleItem.js | 14 +-- .../js/pages/homepage/reducers/error.js | 12 +++ .../js/pages/homepage/reducers/index.js | 3 +- .../js/pages/rules/components/RuleCard.js | 20 ++-- .../tests/homepage/actions/category.test.js | 66 ++++++++++++ .../js/tests/homepage/actions/post.test.js | 85 ++++++++++++++- .../js/tests/homepage/actions/rule.test.js | 85 +++++++++++++++ .../tests/homepage/actions/selected.test.js | 98 +++++++++++++++++- src/newsreader/js/utils.js | 6 +- .../news/core/templates/core/category.html | 8 +- .../scss/components/card/_card.scss | 3 +- .../scss/components/form/_form.scss | 2 +- .../scss/components/messages/_messages.scss | 11 ++ .../scss/components/rules/_rules.scss | 14 ++- src/newsreader/scss/lib/_css.gg.scss | 2 +- src/newsreader/scss/partials/_colors.scss | 2 +- .../static/{icons/rss.png => favicon.png} | Bin src/newsreader/static/icons/alarm.svg | 6 -- src/newsreader/static/icons/angle-down.svg | 1 - src/newsreader/static/icons/angle-right.svg | 1 - src/newsreader/static/icons/apartment.svg | 30 ------ .../static/icons/arrow-down-circle.svg | 7 -- src/newsreader/static/icons/arrow-down.svg | 6 -- .../static/icons/arrow-left-circle.svg | 7 -- src/newsreader/static/icons/arrow-left.svg | 6 -- .../static/icons/arrow-right-circle.svg | 7 -- src/newsreader/static/icons/arrow-right.svg | 6 -- .../static/icons/arrow-up-circle.svg | 7 -- src/newsreader/static/icons/arrow-up.svg | 6 -- src/newsreader/static/icons/bicycle.svg | 9 -- src/newsreader/static/icons/bold.svg | 8 -- src/newsreader/static/icons/book.svg | 7 -- src/newsreader/static/icons/bookmark.svg | 6 -- src/newsreader/static/icons/briefcase.svg | 6 -- src/newsreader/static/icons/bubble.svg | 6 -- src/newsreader/static/icons/bug.svg | 6 -- src/newsreader/static/icons/bullhorn.svg | 6 -- src/newsreader/static/icons/bus.svg | 10 -- src/newsreader/static/icons/calendar-full.svg | 25 ----- src/newsreader/static/icons/camera-video.svg | 7 -- src/newsreader/static/icons/camera.svg | 7 -- src/newsreader/static/icons/car.svg | 8 -- src/newsreader/static/icons/cart.svg | 8 -- src/newsreader/static/icons/chart-bars.svg | 9 -- .../static/icons/checkmark-circle.svg | 7 -- .../static/icons/chevron-down-circle.svg | 7 -- src/newsreader/static/icons/chevron-down.svg | 6 -- .../static/icons/chevron-left-circle.svg | 7 -- src/newsreader/static/icons/chevron-left.svg | 6 -- .../static/icons/chevron-right-circle.svg | 7 -- src/newsreader/static/icons/chevron-right.svg | 6 -- .../static/icons/chevron-up-circle.svg | 7 -- src/newsreader/static/icons/chevron-up.svg | 6 -- src/newsreader/static/icons/circle-minus.svg | 7 -- src/newsreader/static/icons/clock.svg | 7 -- src/newsreader/static/icons/cloud-check.svg | 7 -- .../static/icons/cloud-download.svg | 7 -- src/newsreader/static/icons/cloud-sync.svg | 8 -- src/newsreader/static/icons/cloud-upload.svg | 7 -- src/newsreader/static/icons/cloud.svg | 6 -- src/newsreader/static/icons/code.svg | 8 -- src/newsreader/static/icons/coffee-cup.svg | 7 -- src/newsreader/static/icons/cog.svg | 7 -- src/newsreader/static/icons/construction.svg | 6 -- src/newsreader/static/icons/crop.svg | 9 -- src/newsreader/static/icons/cross-circle.svg | 7 -- src/newsreader/static/icons/cross.svg | 6 -- src/newsreader/static/icons/database.svg | 6 -- src/newsreader/static/icons/diamond.svg | 6 -- src/newsreader/static/icons/dice.svg | 12 --- src/newsreader/static/icons/dinner.svg | 7 -- src/newsreader/static/icons/direction-ltr.svg | 7 -- src/newsreader/static/icons/direction-rtl.svg | 7 -- src/newsreader/static/icons/download.svg | 7 -- src/newsreader/static/icons/drop.svg | 6 -- src/newsreader/static/icons/earth.svg | 6 -- src/newsreader/static/icons/enter-down.svg | 7 -- src/newsreader/static/icons/enter.svg | 7 -- src/newsreader/static/icons/envelope.svg | 6 -- src/newsreader/static/icons/exit-up.svg | 7 -- src/newsreader/static/icons/exit.svg | 7 -- src/newsreader/static/icons/eye.svg | 6 -- .../static/icons/favicon-placeholder.svg | 16 --- src/newsreader/static/icons/file-add.svg | 7 -- src/newsreader/static/icons/file-empty.svg | 6 -- src/newsreader/static/icons/film-play.svg | 7 -- src/newsreader/static/icons/flag.svg | 7 -- .../static/icons/frame-contract.svg | 9 -- src/newsreader/static/icons/frame-expand.svg | 9 -- src/newsreader/static/icons/funnel.svg | 6 -- src/newsreader/static/icons/gift.svg | 6 -- .../static/icons/graduation-hat.svg | 6 -- src/newsreader/static/icons/hand.svg | 6 -- src/newsreader/static/icons/heart-pulse.svg | 8 -- src/newsreader/static/icons/heart.svg | 6 -- src/newsreader/static/icons/highlight.svg | 6 -- src/newsreader/static/icons/history.svg | 7 -- src/newsreader/static/icons/home.svg | 6 -- src/newsreader/static/icons/hourglass.svg | 7 -- src/newsreader/static/icons/inbox.svg | 6 -- .../static/icons/indent-decrease.svg | 11 -- .../static/icons/indent-increase.svg | 11 -- src/newsreader/static/icons/italic.svg | 6 -- src/newsreader/static/icons/keyboard.svg | 27 ----- src/newsreader/static/icons/laptop-phone.svg | 9 -- src/newsreader/static/icons/laptop.svg | 7 -- src/newsreader/static/icons/layers.svg | 8 -- src/newsreader/static/icons/leaf.svg | 6 -- src/newsreader/static/icons/license.svg | 12 --- src/newsreader/static/icons/lighter.svg | 7 -- src/newsreader/static/icons/line-spacing.svg | 10 -- src/newsreader/static/icons/linearicons.svg | 7 -- src/newsreader/static/icons/link.svg | 7 -- src/newsreader/static/icons/list.svg | 11 -- src/newsreader/static/icons/location.svg | 6 -- src/newsreader/static/icons/lock.svg | 6 -- src/newsreader/static/icons/magic-wand.svg | 10 -- src/newsreader/static/icons/magnifier.svg | 6 -- src/newsreader/static/icons/map-marker.svg | 7 -- src/newsreader/static/icons/map.svg | 6 -- src/newsreader/static/icons/menu-circle.svg | 9 -- src/newsreader/static/icons/menu.svg | 8 -- src/newsreader/static/icons/mic.svg | 7 -- src/newsreader/static/icons/moon.svg | 6 -- src/newsreader/static/icons/move.svg | 6 -- src/newsreader/static/icons/music-note.svg | 6 -- src/newsreader/static/icons/mustache.svg | 9 -- src/newsreader/static/icons/neutral.svg | 9 -- src/newsreader/static/icons/page-break.svg | 14 --- src/newsreader/static/icons/paperclip.svg | 6 -- src/newsreader/static/icons/paw.svg | 10 -- src/newsreader/static/icons/pencil.svg | 6 -- src/newsreader/static/icons/phone-handset.svg | 6 -- src/newsreader/static/icons/phone.svg | 8 -- src/newsreader/static/icons/picture.svg | 8 -- src/newsreader/static/icons/pie-chart.svg | 7 -- src/newsreader/static/icons/pilcrow.svg | 6 -- src/newsreader/static/icons/plus-circle.svg | 7 -- src/newsreader/static/icons/pointer-down.svg | 6 -- src/newsreader/static/icons/pointer-left.svg | 6 -- src/newsreader/static/icons/pointer-right.svg | 6 -- src/newsreader/static/icons/pointer-up.svg | 6 -- src/newsreader/static/icons/poop.svg | 6 -- src/newsreader/static/icons/power-switch.svg | 7 -- src/newsreader/static/icons/printer.svg | 10 -- src/newsreader/static/icons/pushpin.svg | 6 -- .../static/icons/question-circle.svg | 8 -- src/newsreader/static/icons/redo.svg | 6 -- src/newsreader/static/icons/rocket.svg | 8 -- src/newsreader/static/icons/sad.svg | 9 -- src/newsreader/static/icons/screen.svg | 6 -- src/newsreader/static/icons/select.svg | 7 -- src/newsreader/static/icons/shirt.svg | 6 -- src/newsreader/static/icons/smartphone.svg | 8 -- src/newsreader/static/icons/smile.svg | 9 -- .../static/icons/sort-alpha-asc.svg | 8 -- .../static/icons/sort-amount-asc.svg | 10 -- src/newsreader/static/icons/spell-check.svg | 9 -- src/newsreader/static/icons/star-empty.svg | 15 --- src/newsreader/static/icons/star-half.svg | 10 -- src/newsreader/static/icons/star.svg | 6 -- src/newsreader/static/icons/store.svg | 10 -- src/newsreader/static/icons/strikethrough.svg | 8 -- src/newsreader/static/icons/sun.svg | 14 --- src/newsreader/static/icons/sync.svg | 7 -- src/newsreader/static/icons/tablet.svg | 8 -- src/newsreader/static/icons/tag.svg | 7 -- .../static/icons/text-align-center.svg | 10 -- .../static/icons/text-align-justify.svg | 10 -- .../static/icons/text-align-left.svg | 10 -- .../static/icons/text-align-right.svg | 10 -- .../static/icons/text-format-remove.svg | 8 -- src/newsreader/static/icons/text-format.svg | 7 -- src/newsreader/static/icons/text-size.svg | 7 -- src/newsreader/static/icons/thumbs-down.svg | 6 -- src/newsreader/static/icons/thumbs-up.svg | 6 -- src/newsreader/static/icons/times.svg | 1 - src/newsreader/static/icons/train.svg | 11 -- src/newsreader/static/icons/trash.svg | 9 -- src/newsreader/static/icons/underline.svg | 7 -- src/newsreader/static/icons/undo.svg | 6 -- src/newsreader/static/icons/unlink.svg | 13 --- src/newsreader/static/icons/upload.svg | 7 -- src/newsreader/static/icons/user.svg | 7 -- src/newsreader/static/icons/users.svg | 9 -- src/newsreader/static/icons/volume-high.svg | 9 -- src/newsreader/static/icons/volume-low.svg | 7 -- src/newsreader/static/icons/volume-medium.svg | 8 -- src/newsreader/static/icons/volume.svg | 6 -- src/newsreader/static/icons/warning.svg | 8 -- src/newsreader/static/icons/wheelchair.svg | 7 -- src/newsreader/templates/base.html | 2 +- webpack.common.babel.js | 12 ++- 204 files changed, 506 insertions(+), 1423 deletions(-) create mode 100644 src/newsreader/js/pages/homepage/actions/error.js create mode 100644 src/newsreader/js/pages/homepage/reducers/error.js rename src/newsreader/static/{icons/rss.png => favicon.png} (100%) delete mode 100755 src/newsreader/static/icons/alarm.svg delete mode 100644 src/newsreader/static/icons/angle-down.svg delete mode 100644 src/newsreader/static/icons/angle-right.svg delete mode 100755 src/newsreader/static/icons/apartment.svg delete mode 100755 src/newsreader/static/icons/arrow-down-circle.svg delete mode 100755 src/newsreader/static/icons/arrow-down.svg delete mode 100755 src/newsreader/static/icons/arrow-left-circle.svg delete mode 100644 src/newsreader/static/icons/arrow-left.svg delete mode 100755 src/newsreader/static/icons/arrow-right-circle.svg delete mode 100755 src/newsreader/static/icons/arrow-right.svg delete mode 100755 src/newsreader/static/icons/arrow-up-circle.svg delete mode 100755 src/newsreader/static/icons/arrow-up.svg delete mode 100755 src/newsreader/static/icons/bicycle.svg delete mode 100755 src/newsreader/static/icons/bold.svg delete mode 100755 src/newsreader/static/icons/book.svg delete mode 100755 src/newsreader/static/icons/bookmark.svg delete mode 100755 src/newsreader/static/icons/briefcase.svg delete mode 100755 src/newsreader/static/icons/bubble.svg delete mode 100755 src/newsreader/static/icons/bug.svg delete mode 100755 src/newsreader/static/icons/bullhorn.svg delete mode 100755 src/newsreader/static/icons/bus.svg delete mode 100755 src/newsreader/static/icons/calendar-full.svg delete mode 100755 src/newsreader/static/icons/camera-video.svg delete mode 100755 src/newsreader/static/icons/camera.svg delete mode 100755 src/newsreader/static/icons/car.svg delete mode 100755 src/newsreader/static/icons/cart.svg delete mode 100755 src/newsreader/static/icons/chart-bars.svg delete mode 100755 src/newsreader/static/icons/checkmark-circle.svg delete mode 100755 src/newsreader/static/icons/chevron-down-circle.svg delete mode 100644 src/newsreader/static/icons/chevron-down.svg delete mode 100755 src/newsreader/static/icons/chevron-left-circle.svg delete mode 100755 src/newsreader/static/icons/chevron-left.svg delete mode 100755 src/newsreader/static/icons/chevron-right-circle.svg delete mode 100644 src/newsreader/static/icons/chevron-right.svg delete mode 100755 src/newsreader/static/icons/chevron-up-circle.svg delete mode 100755 src/newsreader/static/icons/chevron-up.svg delete mode 100755 src/newsreader/static/icons/circle-minus.svg delete mode 100755 src/newsreader/static/icons/clock.svg delete mode 100755 src/newsreader/static/icons/cloud-check.svg delete mode 100755 src/newsreader/static/icons/cloud-download.svg delete mode 100755 src/newsreader/static/icons/cloud-sync.svg delete mode 100755 src/newsreader/static/icons/cloud-upload.svg delete mode 100755 src/newsreader/static/icons/cloud.svg delete mode 100755 src/newsreader/static/icons/code.svg delete mode 100755 src/newsreader/static/icons/coffee-cup.svg delete mode 100755 src/newsreader/static/icons/cog.svg delete mode 100755 src/newsreader/static/icons/construction.svg delete mode 100755 src/newsreader/static/icons/crop.svg delete mode 100755 src/newsreader/static/icons/cross-circle.svg delete mode 100755 src/newsreader/static/icons/cross.svg delete mode 100755 src/newsreader/static/icons/database.svg delete mode 100755 src/newsreader/static/icons/diamond.svg delete mode 100755 src/newsreader/static/icons/dice.svg delete mode 100755 src/newsreader/static/icons/dinner.svg delete mode 100755 src/newsreader/static/icons/direction-ltr.svg delete mode 100755 src/newsreader/static/icons/direction-rtl.svg delete mode 100755 src/newsreader/static/icons/download.svg delete mode 100755 src/newsreader/static/icons/drop.svg delete mode 100755 src/newsreader/static/icons/earth.svg delete mode 100755 src/newsreader/static/icons/enter-down.svg delete mode 100755 src/newsreader/static/icons/enter.svg delete mode 100755 src/newsreader/static/icons/envelope.svg delete mode 100755 src/newsreader/static/icons/exit-up.svg delete mode 100755 src/newsreader/static/icons/exit.svg delete mode 100755 src/newsreader/static/icons/eye.svg delete mode 100644 src/newsreader/static/icons/favicon-placeholder.svg delete mode 100755 src/newsreader/static/icons/file-add.svg delete mode 100755 src/newsreader/static/icons/file-empty.svg delete mode 100755 src/newsreader/static/icons/film-play.svg delete mode 100755 src/newsreader/static/icons/flag.svg delete mode 100755 src/newsreader/static/icons/frame-contract.svg delete mode 100755 src/newsreader/static/icons/frame-expand.svg delete mode 100755 src/newsreader/static/icons/funnel.svg delete mode 100755 src/newsreader/static/icons/gift.svg delete mode 100755 src/newsreader/static/icons/graduation-hat.svg delete mode 100755 src/newsreader/static/icons/hand.svg delete mode 100755 src/newsreader/static/icons/heart-pulse.svg delete mode 100755 src/newsreader/static/icons/heart.svg delete mode 100755 src/newsreader/static/icons/highlight.svg delete mode 100755 src/newsreader/static/icons/history.svg delete mode 100755 src/newsreader/static/icons/home.svg delete mode 100755 src/newsreader/static/icons/hourglass.svg delete mode 100755 src/newsreader/static/icons/inbox.svg delete mode 100755 src/newsreader/static/icons/indent-decrease.svg delete mode 100755 src/newsreader/static/icons/indent-increase.svg delete mode 100755 src/newsreader/static/icons/italic.svg delete mode 100755 src/newsreader/static/icons/keyboard.svg delete mode 100755 src/newsreader/static/icons/laptop-phone.svg delete mode 100755 src/newsreader/static/icons/laptop.svg delete mode 100755 src/newsreader/static/icons/layers.svg delete mode 100755 src/newsreader/static/icons/leaf.svg delete mode 100755 src/newsreader/static/icons/license.svg delete mode 100755 src/newsreader/static/icons/lighter.svg delete mode 100755 src/newsreader/static/icons/line-spacing.svg delete mode 100755 src/newsreader/static/icons/linearicons.svg delete mode 100644 src/newsreader/static/icons/link.svg delete mode 100755 src/newsreader/static/icons/list.svg delete mode 100755 src/newsreader/static/icons/location.svg delete mode 100755 src/newsreader/static/icons/lock.svg delete mode 100755 src/newsreader/static/icons/magic-wand.svg delete mode 100755 src/newsreader/static/icons/magnifier.svg delete mode 100755 src/newsreader/static/icons/map-marker.svg delete mode 100755 src/newsreader/static/icons/map.svg delete mode 100755 src/newsreader/static/icons/menu-circle.svg delete mode 100755 src/newsreader/static/icons/menu.svg delete mode 100755 src/newsreader/static/icons/mic.svg delete mode 100755 src/newsreader/static/icons/moon.svg delete mode 100755 src/newsreader/static/icons/move.svg delete mode 100755 src/newsreader/static/icons/music-note.svg delete mode 100755 src/newsreader/static/icons/mustache.svg delete mode 100755 src/newsreader/static/icons/neutral.svg delete mode 100755 src/newsreader/static/icons/page-break.svg delete mode 100755 src/newsreader/static/icons/paperclip.svg delete mode 100755 src/newsreader/static/icons/paw.svg delete mode 100755 src/newsreader/static/icons/pencil.svg delete mode 100755 src/newsreader/static/icons/phone-handset.svg delete mode 100755 src/newsreader/static/icons/phone.svg delete mode 100755 src/newsreader/static/icons/picture.svg delete mode 100755 src/newsreader/static/icons/pie-chart.svg delete mode 100755 src/newsreader/static/icons/pilcrow.svg delete mode 100755 src/newsreader/static/icons/plus-circle.svg delete mode 100755 src/newsreader/static/icons/pointer-down.svg delete mode 100755 src/newsreader/static/icons/pointer-left.svg delete mode 100755 src/newsreader/static/icons/pointer-right.svg delete mode 100755 src/newsreader/static/icons/pointer-up.svg delete mode 100755 src/newsreader/static/icons/poop.svg delete mode 100755 src/newsreader/static/icons/power-switch.svg delete mode 100755 src/newsreader/static/icons/printer.svg delete mode 100755 src/newsreader/static/icons/pushpin.svg delete mode 100755 src/newsreader/static/icons/question-circle.svg delete mode 100755 src/newsreader/static/icons/redo.svg delete mode 100755 src/newsreader/static/icons/rocket.svg delete mode 100755 src/newsreader/static/icons/sad.svg delete mode 100755 src/newsreader/static/icons/screen.svg delete mode 100755 src/newsreader/static/icons/select.svg delete mode 100755 src/newsreader/static/icons/shirt.svg delete mode 100755 src/newsreader/static/icons/smartphone.svg delete mode 100755 src/newsreader/static/icons/smile.svg delete mode 100755 src/newsreader/static/icons/sort-alpha-asc.svg delete mode 100755 src/newsreader/static/icons/sort-amount-asc.svg delete mode 100755 src/newsreader/static/icons/spell-check.svg delete mode 100755 src/newsreader/static/icons/star-empty.svg delete mode 100755 src/newsreader/static/icons/star-half.svg delete mode 100755 src/newsreader/static/icons/star.svg delete mode 100755 src/newsreader/static/icons/store.svg delete mode 100755 src/newsreader/static/icons/strikethrough.svg delete mode 100755 src/newsreader/static/icons/sun.svg delete mode 100755 src/newsreader/static/icons/sync.svg delete mode 100755 src/newsreader/static/icons/tablet.svg delete mode 100755 src/newsreader/static/icons/tag.svg delete mode 100755 src/newsreader/static/icons/text-align-center.svg delete mode 100755 src/newsreader/static/icons/text-align-justify.svg delete mode 100755 src/newsreader/static/icons/text-align-left.svg delete mode 100755 src/newsreader/static/icons/text-align-right.svg delete mode 100755 src/newsreader/static/icons/text-format-remove.svg delete mode 100755 src/newsreader/static/icons/text-format.svg delete mode 100755 src/newsreader/static/icons/text-size.svg delete mode 100755 src/newsreader/static/icons/thumbs-down.svg delete mode 100755 src/newsreader/static/icons/thumbs-up.svg delete mode 100644 src/newsreader/static/icons/times.svg delete mode 100755 src/newsreader/static/icons/train.svg delete mode 100755 src/newsreader/static/icons/trash.svg delete mode 100755 src/newsreader/static/icons/underline.svg delete mode 100755 src/newsreader/static/icons/undo.svg delete mode 100755 src/newsreader/static/icons/unlink.svg delete mode 100755 src/newsreader/static/icons/upload.svg delete mode 100755 src/newsreader/static/icons/user.svg delete mode 100755 src/newsreader/static/icons/users.svg delete mode 100755 src/newsreader/static/icons/volume-high.svg delete mode 100755 src/newsreader/static/icons/volume-low.svg delete mode 100755 src/newsreader/static/icons/volume-medium.svg delete mode 100755 src/newsreader/static/icons/volume.svg delete mode 100755 src/newsreader/static/icons/warning.svg delete mode 100755 src/newsreader/static/icons/wheelchair.svg diff --git a/package-lock.json b/package-lock.json index 37f67e3..50f72a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2816,6 +2816,11 @@ } } }, + "css.gg": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/css.gg/-/css.gg-1.0.6.tgz", + "integrity": "sha512-Bv8GTVkeuSqqkgdCJ+tJopRxf/mp/wP6hkL13BdCSs3FadD0GWyU3gKdjuaaFkfxkgYK+GhjSX3EA+cXLHBFpA==" + }, "cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", diff --git a/package.json b/package.json index 03d911f..e2c4667 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "lint": "npx prettier \"src/newsreader/js/**/*.js\" --check", "format": "npx prettier \"src/newsreader/js/**/*.js\" --write", "build": "npx webpack --config webpack.dev.babel.js", + "build:watch": "npx webpack --config webpack.dev.babel.js --watch", "build:prod": "npx webpack --config webpack.prod.babel.js", - "watch": "npx webpack --config webpack.dev.babel.js --watch", "test": "npx jest", "test:watch": "npm test -- --watch" }, @@ -19,6 +19,7 @@ "author": "Sonny", "license": "GPL-3.0-or-later", "dependencies": { + "css.gg": "^1.0.6", "js-cookie": "^2.2.1", "lodash": "^4.17.15", "object-assign": "^4.1.1", diff --git a/src/newsreader/js/components/Messages.js b/src/newsreader/js/components/Messages.js index a985381..843677c 100644 --- a/src/newsreader/js/components/Messages.js +++ b/src/newsreader/js/components/Messages.js @@ -1,15 +1,29 @@ import React from 'react'; -const Messages = props => { - const messages = props.messages.map((index, message) => { - return ( -
    • - {message.text} -
    • - ); - }); +class Messages extends React.Component { + state = { messages: this.props.messages }; - return
        {messages}
      ; -}; + close = ::this.close; + + close(index) { + const newMessages = this.state.messages.filter((message, currentIndex) => { + return currentIndex != index; + }); + + this.setState({ messages: newMessages }); + } + + render() { + const messages = this.state.messages.map((message, index) => { + return ( +
    • + {message.text} this.close(index)} /> +
    • + ); + }); + + return
        {messages}
      ; + } +} export default Messages; diff --git a/src/newsreader/js/pages/categories/components/CategoryCard.js b/src/newsreader/js/pages/categories/components/CategoryCard.js index 49bb8c8..a3a242d 100644 --- a/src/newsreader/js/pages/categories/components/CategoryCard.js +++ b/src/newsreader/js/pages/categories/components/CategoryCard.js @@ -6,11 +6,17 @@ const CategoryCard = props => { const { category } = props; const categoryRules = category.rules.map(rule => { - const faviconUrl = rule.favicon ? rule.favicon : '/static/icons/picture.svg'; + let favicon = null; + + if (rule.favicon) { + favicon = ; + } else { + favicon = ; + } return (
    • - + {favicon} {rule.name}
    • ); diff --git a/src/newsreader/js/pages/homepage/App.js b/src/newsreader/js/pages/homepage/App.js index e66a0bd..bdf0149 100644 --- a/src/newsreader/js/pages/homepage/App.js +++ b/src/newsreader/js/pages/homepage/App.js @@ -8,6 +8,7 @@ import { fetchCategories } from './actions/categories'; import Sidebar from './components/sidebar/Sidebar.js'; import FeedList from './components/feedlist/FeedList.js'; import PostModal from './components/PostModal.js'; +import Messages from '../../components/Messages.js'; class App extends React.Component { componentDidMount() { @@ -20,6 +21,10 @@ class App extends React.Component { + {this.props.error && ( + + )} + {!isEqual(this.props.post, {}) && ( { + const { error } = state.error; + if (!isEqual(state.selected.post, {})) { const ruleId = state.selected.post.rule; @@ -40,15 +47,14 @@ const mapStateToProps = state => { const category = state.categories.items[rule.category]; return { - post: state.selected.post, - rule, category, + error, + rule, + post: state.selected.post, }; } - return { - post: state.selected.post, - }; + return { error, post: state.selected.post }; }; const mapDispatchToProps = dispatch => ({ diff --git a/src/newsreader/js/pages/homepage/actions/categories.js b/src/newsreader/js/pages/homepage/actions/categories.js index 0fc63a6..d569f53 100644 --- a/src/newsreader/js/pages/homepage/actions/categories.js +++ b/src/newsreader/js/pages/homepage/actions/categories.js @@ -1,4 +1,5 @@ import { requestRules, receiveRules, fetchRulesByCategory } from './rules.js'; +import { handleAPIError } from './error.js'; import { CATEGORY_TYPE } from '../constants.js'; @@ -47,6 +48,10 @@ export const fetchCategory = category => { if (category.unread === 0) { return dispatch(fetchRulesByCategory(category)); } + }) + .catch(error => { + dispatch(receiveCategory({})); + dispatch(handleAPIError(error)); }); }; }; @@ -72,6 +77,11 @@ export const fetchCategories = () => { return Promise.all(promises); }) .then(responses => Promise.all(responses.map(response => response.json()))) - .then(nestedRules => dispatch(receiveRules(nestedRules.flat()))); + .then(nestedRules => dispatch(receiveRules(nestedRules.flat()))) + .catch(error => { + dispatch(receiveCategories([])); + dispatch(receiveRules([])); + dispatch(handleAPIError(error)); + }); }; }; diff --git a/src/newsreader/js/pages/homepage/actions/error.js b/src/newsreader/js/pages/homepage/actions/error.js new file mode 100644 index 0000000..6449f84 --- /dev/null +++ b/src/newsreader/js/pages/homepage/actions/error.js @@ -0,0 +1,6 @@ +export const RECEIVE_API_ERROR = 'RECEIVE_API_ERROR'; + +export const handleAPIError = error => ({ + type: RECEIVE_API_ERROR, + error, +}); diff --git a/src/newsreader/js/pages/homepage/actions/posts.js b/src/newsreader/js/pages/homepage/actions/posts.js index d1fc79b..b7ad5cb 100644 --- a/src/newsreader/js/pages/homepage/actions/posts.js +++ b/src/newsreader/js/pages/homepage/actions/posts.js @@ -1,3 +1,4 @@ +import { handleAPIError } from './error.js'; import { RULE_TYPE, CATEGORY_TYPE } from '../constants.js'; export const SELECT_POST = 'SELECT_POST'; @@ -50,6 +51,10 @@ export const markPostRead = (post, token) => { .then(updatedPost => { dispatch(receivePost({ ...updatedPost })); dispatch(postRead({ ...updatedPost }, section)); + }) + .catch(error => { + dispatch(receivePost({})); + dispatch(handleAPIError(error)); }); }; }; @@ -77,11 +82,8 @@ export const fetchPostsBySection = (section, page = false) => { .then(response => response.json()) .then(posts => dispatch(receivePosts(posts.results, posts.next))) .catch(error => { - if (error instanceof TypeError) { - console.log(`Unable to parse posts from request: ${error}`); - } - - dispatch(receivePosts({}, null)); + dispatch(receivePosts([])); + dispatch(handleAPIError(error)); }); }; }; diff --git a/src/newsreader/js/pages/homepage/actions/rules.js b/src/newsreader/js/pages/homepage/actions/rules.js index 0f45f1d..98b494e 100644 --- a/src/newsreader/js/pages/homepage/actions/rules.js +++ b/src/newsreader/js/pages/homepage/actions/rules.js @@ -1,5 +1,6 @@ import { fetchCategory } from './categories.js'; import { RULE_TYPE } from '../constants.js'; +import { handleAPIError } from './error.js'; export const SELECT_RULE = 'SELECT_RULE'; export const SELECT_RULES = 'SELECT_RULES'; @@ -51,6 +52,10 @@ export const fetchRule = rule => { if (rule.unread === 0) { return dispatch(fetchCategory({ ...category })); } + }) + .catch(error => { + dispatch(receiveRule({})); + dispatch(handleAPIError(error)); }); }; }; @@ -61,6 +66,10 @@ export const fetchRulesByCategory = category => { return fetch(`/api/categories/${category.id}/rules/`) .then(response => response.json()) - .then(rules => dispatch(receiveRules(rules))); + .then(rules => dispatch(receiveRules(rules))) + .catch(error => { + dispatch(receiveRules([])); + dispatch(handleAPIError(error)); + }); }; }; diff --git a/src/newsreader/js/pages/homepage/actions/selected.js b/src/newsreader/js/pages/homepage/actions/selected.js index f767d42..189cad6 100644 --- a/src/newsreader/js/pages/homepage/actions/selected.js +++ b/src/newsreader/js/pages/homepage/actions/selected.js @@ -1,3 +1,4 @@ +import { handleAPIError } from './error.js'; import { receiveCategory, requestCategory } from './categories.js'; import { receiveRule, requestRule } from './rules.js'; import { CATEGORY_TYPE, RULE_TYPE } from '../constants.js'; @@ -43,6 +44,10 @@ const markCategoryRead = (category, token) => { type: CATEGORY_TYPE, }) ); + }) + .catch(error => { + dispatch(receiveCategory({})); + dispatch(handleAPIError(error)); }); }; }; @@ -66,6 +71,10 @@ const markRuleRead = (rule, token) => { // Use the old rule to decrement category with old unread count dispatch(markSectionRead({ ...rule, type: RULE_TYPE })); + }) + .catch(error => { + dispatch(receiveRule({})); + dispatch(handleAPIError(error)); }); }; }; diff --git a/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js b/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js index 0477fa7..e873965 100644 --- a/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js +++ b/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js @@ -41,14 +41,7 @@ class FeedList extends React.Component { return ; }); - if (ruleItems.length > 0) { - return ( -
      - {ruleItems} - {this.props.isFetching && } -
      - ); - } else if (isEqual(this.props.selected, {})) { + if (isEqual(this.props.selected, {})) { return (
      @@ -57,7 +50,7 @@ class FeedList extends React.Component {
      ); - } else if (ruleItems.length === 0) { + } else if (ruleItems.length === 0 && !this.props.isFetching) { return (
      @@ -69,7 +62,10 @@ class FeedList extends React.Component { ); } else { return ( -
      {this.props.isFetching && }
      +
      + {ruleItems} + {this.props.isFetching && } +
      ); } } diff --git a/src/newsreader/js/pages/homepage/components/sidebar/RuleItem.js b/src/newsreader/js/pages/homepage/components/sidebar/RuleItem.js index a784462..879745f 100644 --- a/src/newsreader/js/pages/homepage/components/sidebar/RuleItem.js +++ b/src/newsreader/js/pages/homepage/components/sidebar/RuleItem.js @@ -19,16 +19,18 @@ class RuleItem extends React.Component { render() { const selected = isSelected(this.props.rule, this.props.selected, RULE_TYPE); const className = `rules__item ${selected ? 'rules__item--selected' : ''}`; - const favicon = this.props.rule.favicon - ? this.props.rule.favicon - : '/static/icons/picture.svg'; + let favicon = null; + + if (this.props.rule.favicon) { + favicon = ; + } else { + favicon = ; + } return (
    • this.handleSelect()}>
      - - - + {favicon}
      {this.props.rule.name}
      diff --git a/src/newsreader/js/pages/homepage/reducers/error.js b/src/newsreader/js/pages/homepage/reducers/error.js new file mode 100644 index 0000000..3d20b14 --- /dev/null +++ b/src/newsreader/js/pages/homepage/reducers/error.js @@ -0,0 +1,12 @@ +import { RECEIVE_API_ERROR } from '../actions/error.js'; + +const defaultState = {}; + +export const error = (state = { ...defaultState }, action) => { + switch (action.type) { + case RECEIVE_API_ERROR: + return { ...state, error: action.error }; + default: + return {}; + } +}; diff --git a/src/newsreader/js/pages/homepage/reducers/index.js b/src/newsreader/js/pages/homepage/reducers/index.js index f70ca2a..20dea78 100644 --- a/src/newsreader/js/pages/homepage/reducers/index.js +++ b/src/newsreader/js/pages/homepage/reducers/index.js @@ -1,10 +1,11 @@ import { combineReducers } from 'redux'; import { categories } from './categories.js'; +import { error } from './error.js'; import { rules } from './rules.js'; import { posts } from './posts.js'; import { selected } from './selected.js'; -const rootReducer = combineReducers({ categories, rules, posts, selected }); +const rootReducer = combineReducers({ categories, error, rules, posts, selected }); export default rootReducer; diff --git a/src/newsreader/js/pages/rules/components/RuleCard.js b/src/newsreader/js/pages/rules/components/RuleCard.js index 07d4e99..d74b8d1 100644 --- a/src/newsreader/js/pages/rules/components/RuleCard.js +++ b/src/newsreader/js/pages/rules/components/RuleCard.js @@ -4,19 +4,21 @@ import Card from '../../../components/Card.js'; const RuleCard = props => { const { rule } = props; + let favicon = null; - const faviconUrl = rule.favicon ? rule.favicon : '/static/icons/picture.svg'; - const stateIcon = !rule.error - ? '/static/icons/checkmark-circle.svg' - : '/static/icons/warning.svg'; + if (rule.favicon) { + favicon = ; + } else { + favicon = ; + } + + const stateIcon = !rule.error ? 'gg-check' : 'gg-danger'; const cardHeader = ( <> -
      - -

      {rule.name}

      -
      - + +

      {rule.name}

      + {favicon} ); diff --git a/src/newsreader/js/tests/homepage/actions/category.test.js b/src/newsreader/js/tests/homepage/actions/category.test.js index a6be5ad..8c0266c 100644 --- a/src/newsreader/js/tests/homepage/actions/category.test.js +++ b/src/newsreader/js/tests/homepage/actions/category.test.js @@ -5,6 +5,7 @@ import fetchMock from 'fetch-mock'; import * as actions from '../../../pages/homepage/actions/categories.js'; import * as constants from '../../../pages/homepage/constants.js'; import * as ruleActions from '../../../pages/homepage/actions/rules.js'; +import * as errorActions from '../../../pages/homepage/actions/error.js'; const middlewares = [thunk]; const mockStore = configureMockStore(middlewares); @@ -247,4 +248,69 @@ describe('category actions', () => { expect(store.getActions()).toEqual(expectedActions); }); + + it('should handle an unexpected response when fetching a category', () => { + const category = { + id: 1, + name: 'Tech', + unread: 1138, + }; + + const errorMessage = 'Key id not found'; + + fetchMock.getOnce('/api/categories/1', () => { + throw new TypeError(errorMessage); + }); + + const expectedActions = [ + { type: actions.REQUEST_CATEGORY }, + { type: actions.RECEIVE_CATEGORY, category: {} }, + { type: errorActions.RECEIVE_API_ERROR, error: TypeError(errorMessage) }, + ]; + + const store = mockStore({ + categories: { items: {}, isFetching: false }, + rules: { items: {}, isFetching: false }, + posts: { items: {}, isFetching: false }, + selected: { item: {}, next: false, lastReached: false, post: {} }, + error: { error: {} }, + }); + + return store.dispatch(actions.fetchCategory(category)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it('should handle an unexpected response when multiple categories', () => { + const category = { + id: 1, + name: 'Tech', + unread: 1138, + }; + + const errorMessage = 'URL not found'; + + fetchMock.getOnce('/api/categories/', () => { + throw new Error(errorMessage); + }); + + const expectedActions = [ + { type: actions.REQUEST_CATEGORIES }, + { type: actions.RECEIVE_CATEGORIES, categories: [] }, + { type: ruleActions.RECEIVE_RULES, rules: [] }, + { type: errorActions.RECEIVE_API_ERROR, error: Error(errorMessage) }, + ]; + + const store = mockStore({ + categories: { items: {}, isFetching: false }, + rules: { items: {}, isFetching: false }, + posts: { items: {}, isFetching: false }, + selected: { item: {}, next: false, lastReached: false, post: {} }, + error: { error: {} }, + }); + + return store.dispatch(actions.fetchCategories()).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); }); diff --git a/src/newsreader/js/tests/homepage/actions/post.test.js b/src/newsreader/js/tests/homepage/actions/post.test.js index 65967b4..e8f84de 100644 --- a/src/newsreader/js/tests/homepage/actions/post.test.js +++ b/src/newsreader/js/tests/homepage/actions/post.test.js @@ -3,12 +3,13 @@ import thunk from 'redux-thunk'; import fetchMock from 'fetch-mock'; import * as actions from '../../../pages/homepage/actions/posts.js'; +import * as errorActions from '../../../pages/homepage/actions/error.js'; import * as constants from '../../../pages/homepage/constants.js'; const middlewares = [thunk]; const mockStore = configureMockStore(middlewares); -describe('rule actions', () => { +describe('post actions', () => { afterEach(() => { fetchMock.restore(); }); @@ -322,4 +323,86 @@ describe('rule actions', () => { expect(store.getActions()).toEqual(expectedActions); }); + + it('should handle exceptions when marking a post read', () => { + const post = { + id: 2067, + remoteIdentifier: 'https://arstechnica.com/?p=1648607', + title: + 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', + body: + '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', + author: 'Kyle Orland', + publicationDate: '2020-01-24T19:50:12Z', + url: 'https://arstechnica.com/?p=1648607', + rule: 5, + read: false, + }; + + const rule = { + id: 4, + name: 'Ars Technica', + unread: 100, + category: 1, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }; + + const errorMessage = 'Permission denied'; + + fetchMock.patch(`/api/posts/${post.id}/`, () => { + throw new Error(errorMessage); + }); + + const store = mockStore({ + categories: { items: {}, isFetching: false }, + rules: { items: {}, isFetching: false }, + posts: { items: {}, isFetching: false }, + selected: { item: { ...rule }, next: false, lastReached: false, post: {} }, + }); + + const expectedActions = [ + { type: actions.RECEIVE_POST, post: {} }, + { type: errorActions.RECEIVE_API_ERROR, error: TypeError(errorMessage) }, + ]; + + return store.dispatch(actions.markPostRead(post, 'FAKE_TOKEN')).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it('should handle exceptions when fetching posts by section', () => { + const rule = { + id: 4, + name: 'Ars Technica', + unread: 100, + category: 1, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + type: constants.RULE_TYPE, + }; + + const errorMessage = 'Page not found'; + + fetchMock.getOnce(`/api/rules/${rule.id}/posts/?read=false`, () => { + throw new Error(errorMessage); + }); + + const store = mockStore({ + categories: { items: {}, isFetching: false }, + rules: { items: {}, isFetching: false }, + posts: { items: {}, isFetching: false }, + selected: { item: { ...rule }, next: false, lastReached: false, post: {} }, + }); + + const expectedActions = [ + { type: actions.REQUEST_POSTS }, + { type: actions.RECEIVE_POSTS, posts: [] }, + { type: errorActions.RECEIVE_API_ERROR, error: Error(errorMessage) }, + ]; + + return store.dispatch(actions.fetchPostsBySection(rule)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); }); diff --git a/src/newsreader/js/tests/homepage/actions/rule.test.js b/src/newsreader/js/tests/homepage/actions/rule.test.js index 70a3a89..7e167e7 100644 --- a/src/newsreader/js/tests/homepage/actions/rule.test.js +++ b/src/newsreader/js/tests/homepage/actions/rule.test.js @@ -7,6 +7,7 @@ import { objectsFromArray } from '../../../utils.js'; import * as actions from '../../../pages/homepage/actions/rules.js'; import * as constants from '../../../pages/homepage/constants.js'; import * as categoryActions from '../../../pages/homepage/actions/categories.js'; +import * as errorActions from '../../../pages/homepage/actions/error.js'; const middlewares = [thunk]; const mockStore = configureMockStore(middlewares); @@ -253,4 +254,88 @@ describe('rule actions', () => { expect(store.getActions()).toEqual(expectedActions); }); }); + + it('should handle an unexpected response when fetching a rule', () => { + const rule = { + id: 1, + name: 'Test rule', + unread: 100, + category: 1, + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + }; + + const errorMessage = 'Too many requests'; + + fetchMock.getOnce('/api/rules/1', () => { + throw new TypeError(errorMessage); + }); + + const store = mockStore({ + categories: { items: {}, isFetching: false }, + rules: { items: {}, isFetching: false }, + posts: { items: {}, isFetching: false }, + selected: { item: {}, next: false, lastReached: false, post: {} }, + }); + + const expectedActions = [ + { type: actions.REQUEST_RULE }, + { type: actions.RECEIVE_RULE, rule: {} }, + { type: errorActions.RECEIVE_API_ERROR, error: TypeError(errorMessage) }, + ]; + + return store.dispatch(actions.fetchRule(rule)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it('should handle an unexpected response when fetching rules by category', () => { + const category = { + id: 1, + name: 'Tech', + unread: 0, + }; + + const rules = [ + { + id: 1, + name: 'Ars Technica', + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + category: 1, + unread: 200, + }, + { + id: 2, + name: 'Hacker News', + url: 'https://news.ycombinator.com/rss', + favicon: 'https://news.ycombinator.com/favicon.ico', + category: 1, + unread: 350, + }, + ]; + + const errorMessage = 'Too many request'; + + fetchMock.getOnce('/api/categories/1/rules/', () => { + throw new Error(errorMessage); + }); + + const expectedActions = [ + { type: actions.REQUEST_RULES }, + { type: actions.RECEIVE_RULES, rules: [] }, + { type: errorActions.RECEIVE_API_ERROR, error: Error(errorMessage) }, + ]; + + const store = mockStore({ + categories: { items: {}, isFetching: false }, + rules: { items: {}, isFetching: false }, + posts: { items: {}, isFetching: false }, + selected: {}, + }); + + return store.dispatch(actions.fetchRulesByCategory(category)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); }); diff --git a/src/newsreader/js/tests/homepage/actions/selected.test.js b/src/newsreader/js/tests/homepage/actions/selected.test.js index a55d232..b0f163c 100644 --- a/src/newsreader/js/tests/homepage/actions/selected.test.js +++ b/src/newsreader/js/tests/homepage/actions/selected.test.js @@ -4,13 +4,14 @@ import fetchMock from 'fetch-mock'; import * as actions from '../../../pages/homepage/actions/selected.js'; import * as categoryActions from '../../../pages/homepage/actions/categories.js'; +import * as errorActions from '../../../pages/homepage/actions/error.js'; import * as ruleActions from '../../../pages/homepage/actions/rules.js'; import * as constants from '../../../pages/homepage/constants.js'; const middlewares = [thunk]; const mockStore = configureMockStore(middlewares); -describe('category actions', () => { +describe('selected actions', () => { afterEach(() => { fetchMock.restore(); }); @@ -138,4 +139,99 @@ describe('category actions', () => { expect(store.getActions()).toEqual(expectedActions); }); }); + + it('should handle exceptions when marking a category as read', () => { + const category = { id: 1, name: 'Test category', unread: 100 }; + const rules = { + 1: { + id: 1, + name: 'Ars Technica', + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + category: 1, + unread: 200, + }, + 2: { + id: 2, + name: 'Hacker News', + url: 'https://news.ycombinator.com/rss', + favicon: 'https://news.ycombinator.com/favicon.ico', + category: 1, + unread: 350, + }, + }; + + const errorMessage = 'Page not found'; + + fetchMock.postOnce('/api/categories/1/read/', () => { + throw new Error(errorMessage); + }); + + const expectedActions = [ + { type: categoryActions.REQUEST_CATEGORY }, + { type: categoryActions.RECEIVE_CATEGORY, category: {} }, + { type: errorActions.RECEIVE_API_ERROR, error: Error(errorMessage) }, + ]; + + const store = mockStore({ + categories: { items: {}, isFetching: false }, + rules: { items: { ...rules }, isFetching: false }, + posts: { items: {}, isFetching: false }, + selected: { + item: { ...category, type: actions.CATEGORY_TYPE }, + next: false, + lastReached: false, + post: {}, + }, + error: {}, + }); + + return store + .dispatch(actions.markRead({ ...category, type: constants.CATEGORY_TYPE }, 'TOKEN')) + .then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it('should handle exceptions when marking a rule as read', () => { + const rule = { + id: 1, + name: 'Ars Technica', + url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', + favicon: 'https://cdn.arstechnica.net/favicon.ico', + category: 1, + unread: 200, + }; + + const errorMessage = 'Page not found'; + + fetchMock.postOnce('/api/rules/1/read/', () => { + throw new Error(errorMessage); + }); + + const expectedActions = [ + { type: ruleActions.REQUEST_RULE }, + { type: ruleActions.RECEIVE_RULE, rule: {} }, + { type: errorActions.RECEIVE_API_ERROR, error: Error(errorMessage) }, + ]; + + const store = mockStore({ + categories: { items: {}, isFetching: false }, + rules: { items: { [rule.id]: { ...rule } }, isFetching: false }, + posts: { items: {}, isFetching: false }, + selected: { + item: { ...rule, type: constants.RULE_TYPE }, + next: false, + lastReached: false, + post: {}, + }, + error: {}, + }); + + return store + .dispatch(actions.markRead({ ...rule, type: constants.RULE_TYPE }, 'TOKEN')) + .then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); }); diff --git a/src/newsreader/js/utils.js b/src/newsreader/js/utils.js index 9db723e..bba4717 100644 --- a/src/newsreader/js/utils.js +++ b/src/newsreader/js/utils.js @@ -1,11 +1,11 @@ export const formatDatetime = dateString => { const locale = navigator.language ? navigator.language : 'en-US'; const dateOptions = { - hour: '2-digit', - weekday: 'long', year: 'numeric', - month: 'long', + month: 'numeric', day: 'numeric', + minute: 'numeric', + hour: 'numeric', }; const date = new Date(dateString); diff --git a/src/newsreader/news/core/templates/core/category.html b/src/newsreader/news/core/templates/core/category.html index dfe7318..0771345 100644 --- a/src/newsreader/news/core/templates/core/category.html +++ b/src/newsreader/news/core/templates/core/category.html @@ -38,8 +38,12 @@ {% if category and rule.pk in category.rule_ids %}checked{% endif %} value="{{ rule.pk }}" /> - + {% if rule.favicon %} + + {% else %} + + {% endif %} + {{ rule.name }}
    • {% endfor %} diff --git a/src/newsreader/scss/components/card/_card.scss b/src/newsreader/scss/components/card/_card.scss index 6e544f6..a9f957e 100644 --- a/src/newsreader/scss/components/card/_card.scss +++ b/src/newsreader/scss/components/card/_card.scss @@ -31,7 +31,6 @@ } & .favicon { - height: 30px; - margin: 0 20px 0 0; + height: 20px; } } diff --git a/src/newsreader/scss/components/form/_form.scss b/src/newsreader/scss/components/form/_form.scss index d99c246..931fba9 100644 --- a/src/newsreader/scss/components/form/_form.scss +++ b/src/newsreader/scss/components/form/_form.scss @@ -24,7 +24,7 @@ } & .favicon { - height: 30px; + height: 20px; } } diff --git a/src/newsreader/scss/components/messages/_messages.scss b/src/newsreader/scss/components/messages/_messages.scss index 56af888..5779820 100644 --- a/src/newsreader/scss/components/messages/_messages.scss +++ b/src/newsreader/scss/components/messages/_messages.scss @@ -3,6 +3,9 @@ flex-direction: column; align-items: center; + position: fixed; + top: 0; + width: 100%; margin: 5px 0 20px 0; color: $white; @@ -10,6 +13,7 @@ &__item { width: 80%; + position: relative; padding: 20px 15px; margin: 5px 0; @@ -28,5 +32,12 @@ &--success { background-color: $success-green; } + + & .gg-close { + position: absolute; + top: 15px; + right: 15px; + --ggs: 2; + } } } diff --git a/src/newsreader/scss/components/rules/_rules.scss b/src/newsreader/scss/components/rules/_rules.scss index a1e9f67..029a070 100644 --- a/src/newsreader/scss/components/rules/_rules.scss +++ b/src/newsreader/scss/components/rules/_rules.scss @@ -14,10 +14,6 @@ padding: 0 2px 0 2px; } - & div { - padding: 0; - } - &:hover { cursor: pointer; background-color: darken($azureish-white, +10%); @@ -32,6 +28,16 @@ display: flex; align-items: center; width: 80%; + + & .gg-image { + --ggs: 80%; + margin: 0 5px 0 0; + min-width: 20px; + } + + & .favicon { + margin: 0 5px 0 0; + } } &__title { diff --git a/src/newsreader/scss/lib/_css.gg.scss b/src/newsreader/scss/lib/_css.gg.scss index fbdbaa7..389e533 100644 --- a/src/newsreader/scss/lib/_css.gg.scss +++ b/src/newsreader/scss/lib/_css.gg.scss @@ -1 +1 @@ -@import url("https://css.gg/c"); +@import "~css.gg/icons-scss/icons"; diff --git a/src/newsreader/scss/partials/_colors.scss b/src/newsreader/scss/partials/_colors.scss index 0259883..664ddf7 100644 --- a/src/newsreader/scss/partials/_colors.scss +++ b/src/newsreader/scss/partials/_colors.scss @@ -28,7 +28,7 @@ $light-orange: rgba(237, 212, 178, 1); $light-red: rgba(255, 118, 117, 1); $success-green: rgba(46,204,113, 1); -$error-red: rgba(231,76,60, 1); +$error-red: lighten(rgba(231, 76, 60, 1), 10%); $confirm-green: $success-green; $cancel-red: $error-red; diff --git a/src/newsreader/static/icons/rss.png b/src/newsreader/static/favicon.png similarity index 100% rename from src/newsreader/static/icons/rss.png rename to src/newsreader/static/favicon.png diff --git a/src/newsreader/static/icons/alarm.svg b/src/newsreader/static/icons/alarm.svg deleted file mode 100755 index 94c3105..0000000 --- a/src/newsreader/static/icons/alarm.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/angle-down.svg b/src/newsreader/static/icons/angle-down.svg deleted file mode 100644 index 1462342..0000000 --- a/src/newsreader/static/icons/angle-down.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/newsreader/static/icons/angle-right.svg b/src/newsreader/static/icons/angle-right.svg deleted file mode 100644 index ec7fbe9..0000000 --- a/src/newsreader/static/icons/angle-right.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/newsreader/static/icons/apartment.svg b/src/newsreader/static/icons/apartment.svg deleted file mode 100755 index ff14506..0000000 --- a/src/newsreader/static/icons/apartment.svg +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/newsreader/static/icons/arrow-down-circle.svg b/src/newsreader/static/icons/arrow-down-circle.svg deleted file mode 100755 index c09ee4e..0000000 --- a/src/newsreader/static/icons/arrow-down-circle.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/arrow-down.svg b/src/newsreader/static/icons/arrow-down.svg deleted file mode 100755 index d802122..0000000 --- a/src/newsreader/static/icons/arrow-down.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/arrow-left-circle.svg b/src/newsreader/static/icons/arrow-left-circle.svg deleted file mode 100755 index a135160..0000000 --- a/src/newsreader/static/icons/arrow-left-circle.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/arrow-left.svg b/src/newsreader/static/icons/arrow-left.svg deleted file mode 100644 index c1168f6..0000000 --- a/src/newsreader/static/icons/arrow-left.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/arrow-right-circle.svg b/src/newsreader/static/icons/arrow-right-circle.svg deleted file mode 100755 index 9b8d2f5..0000000 --- a/src/newsreader/static/icons/arrow-right-circle.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/arrow-right.svg b/src/newsreader/static/icons/arrow-right.svg deleted file mode 100755 index 21c0a0c..0000000 --- a/src/newsreader/static/icons/arrow-right.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/arrow-up-circle.svg b/src/newsreader/static/icons/arrow-up-circle.svg deleted file mode 100755 index 044fc48..0000000 --- a/src/newsreader/static/icons/arrow-up-circle.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/arrow-up.svg b/src/newsreader/static/icons/arrow-up.svg deleted file mode 100755 index fa2d12e..0000000 --- a/src/newsreader/static/icons/arrow-up.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/bicycle.svg b/src/newsreader/static/icons/bicycle.svg deleted file mode 100755 index 2599f0d..0000000 --- a/src/newsreader/static/icons/bicycle.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/src/newsreader/static/icons/bold.svg b/src/newsreader/static/icons/bold.svg deleted file mode 100755 index 86271f6..0000000 --- a/src/newsreader/static/icons/bold.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/newsreader/static/icons/book.svg b/src/newsreader/static/icons/book.svg deleted file mode 100755 index cc4892e..0000000 --- a/src/newsreader/static/icons/book.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/bookmark.svg b/src/newsreader/static/icons/bookmark.svg deleted file mode 100755 index 6057646..0000000 --- a/src/newsreader/static/icons/bookmark.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/briefcase.svg b/src/newsreader/static/icons/briefcase.svg deleted file mode 100755 index 58d54b6..0000000 --- a/src/newsreader/static/icons/briefcase.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/bubble.svg b/src/newsreader/static/icons/bubble.svg deleted file mode 100755 index 87317cc..0000000 --- a/src/newsreader/static/icons/bubble.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/bug.svg b/src/newsreader/static/icons/bug.svg deleted file mode 100755 index 7cedf5a..0000000 --- a/src/newsreader/static/icons/bug.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/bullhorn.svg b/src/newsreader/static/icons/bullhorn.svg deleted file mode 100755 index bc8ffcc..0000000 --- a/src/newsreader/static/icons/bullhorn.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/bus.svg b/src/newsreader/static/icons/bus.svg deleted file mode 100755 index 1a3416f..0000000 --- a/src/newsreader/static/icons/bus.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/newsreader/static/icons/calendar-full.svg b/src/newsreader/static/icons/calendar-full.svg deleted file mode 100755 index c835eaa..0000000 --- a/src/newsreader/static/icons/calendar-full.svg +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/newsreader/static/icons/camera-video.svg b/src/newsreader/static/icons/camera-video.svg deleted file mode 100755 index 99e6ebe..0000000 --- a/src/newsreader/static/icons/camera-video.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/camera.svg b/src/newsreader/static/icons/camera.svg deleted file mode 100755 index b1e662a..0000000 --- a/src/newsreader/static/icons/camera.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/car.svg b/src/newsreader/static/icons/car.svg deleted file mode 100755 index dd9af01..0000000 --- a/src/newsreader/static/icons/car.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/newsreader/static/icons/cart.svg b/src/newsreader/static/icons/cart.svg deleted file mode 100755 index cf0df35..0000000 --- a/src/newsreader/static/icons/cart.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/newsreader/static/icons/chart-bars.svg b/src/newsreader/static/icons/chart-bars.svg deleted file mode 100755 index 15b0f54..0000000 --- a/src/newsreader/static/icons/chart-bars.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/src/newsreader/static/icons/checkmark-circle.svg b/src/newsreader/static/icons/checkmark-circle.svg deleted file mode 100755 index e46b93f..0000000 --- a/src/newsreader/static/icons/checkmark-circle.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/chevron-down-circle.svg b/src/newsreader/static/icons/chevron-down-circle.svg deleted file mode 100755 index e530413..0000000 --- a/src/newsreader/static/icons/chevron-down-circle.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/chevron-down.svg b/src/newsreader/static/icons/chevron-down.svg deleted file mode 100644 index 5342155..0000000 --- a/src/newsreader/static/icons/chevron-down.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/chevron-left-circle.svg b/src/newsreader/static/icons/chevron-left-circle.svg deleted file mode 100755 index 495f43b..0000000 --- a/src/newsreader/static/icons/chevron-left-circle.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/chevron-left.svg b/src/newsreader/static/icons/chevron-left.svg deleted file mode 100755 index 4117261..0000000 --- a/src/newsreader/static/icons/chevron-left.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/chevron-right-circle.svg b/src/newsreader/static/icons/chevron-right-circle.svg deleted file mode 100755 index b5bd4d4..0000000 --- a/src/newsreader/static/icons/chevron-right-circle.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/chevron-right.svg b/src/newsreader/static/icons/chevron-right.svg deleted file mode 100644 index 14db8e8..0000000 --- a/src/newsreader/static/icons/chevron-right.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/chevron-up-circle.svg b/src/newsreader/static/icons/chevron-up-circle.svg deleted file mode 100755 index 9e8acdd..0000000 --- a/src/newsreader/static/icons/chevron-up-circle.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/chevron-up.svg b/src/newsreader/static/icons/chevron-up.svg deleted file mode 100755 index 778bbaa..0000000 --- a/src/newsreader/static/icons/chevron-up.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/circle-minus.svg b/src/newsreader/static/icons/circle-minus.svg deleted file mode 100755 index 1e4a8c9..0000000 --- a/src/newsreader/static/icons/circle-minus.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/clock.svg b/src/newsreader/static/icons/clock.svg deleted file mode 100755 index 55169fe..0000000 --- a/src/newsreader/static/icons/clock.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/cloud-check.svg b/src/newsreader/static/icons/cloud-check.svg deleted file mode 100755 index 253b386..0000000 --- a/src/newsreader/static/icons/cloud-check.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/cloud-download.svg b/src/newsreader/static/icons/cloud-download.svg deleted file mode 100755 index 1cbe14c..0000000 --- a/src/newsreader/static/icons/cloud-download.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/cloud-sync.svg b/src/newsreader/static/icons/cloud-sync.svg deleted file mode 100755 index c239e0d..0000000 --- a/src/newsreader/static/icons/cloud-sync.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/newsreader/static/icons/cloud-upload.svg b/src/newsreader/static/icons/cloud-upload.svg deleted file mode 100755 index 9fcbabe..0000000 --- a/src/newsreader/static/icons/cloud-upload.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/cloud.svg b/src/newsreader/static/icons/cloud.svg deleted file mode 100755 index 24918b1..0000000 --- a/src/newsreader/static/icons/cloud.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/code.svg b/src/newsreader/static/icons/code.svg deleted file mode 100755 index fbdfd39..0000000 --- a/src/newsreader/static/icons/code.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/newsreader/static/icons/coffee-cup.svg b/src/newsreader/static/icons/coffee-cup.svg deleted file mode 100755 index 478ee27..0000000 --- a/src/newsreader/static/icons/coffee-cup.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/cog.svg b/src/newsreader/static/icons/cog.svg deleted file mode 100755 index 4b4948a..0000000 --- a/src/newsreader/static/icons/cog.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/construction.svg b/src/newsreader/static/icons/construction.svg deleted file mode 100755 index 72726ff..0000000 --- a/src/newsreader/static/icons/construction.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/crop.svg b/src/newsreader/static/icons/crop.svg deleted file mode 100755 index 1568611..0000000 --- a/src/newsreader/static/icons/crop.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/src/newsreader/static/icons/cross-circle.svg b/src/newsreader/static/icons/cross-circle.svg deleted file mode 100755 index dbc08af..0000000 --- a/src/newsreader/static/icons/cross-circle.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/cross.svg b/src/newsreader/static/icons/cross.svg deleted file mode 100755 index 2edad61..0000000 --- a/src/newsreader/static/icons/cross.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/database.svg b/src/newsreader/static/icons/database.svg deleted file mode 100755 index 64236ad..0000000 --- a/src/newsreader/static/icons/database.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/diamond.svg b/src/newsreader/static/icons/diamond.svg deleted file mode 100755 index 679df4a..0000000 --- a/src/newsreader/static/icons/diamond.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/dice.svg b/src/newsreader/static/icons/dice.svg deleted file mode 100755 index 6859d8b..0000000 --- a/src/newsreader/static/icons/dice.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/src/newsreader/static/icons/dinner.svg b/src/newsreader/static/icons/dinner.svg deleted file mode 100755 index 0cf54d6..0000000 --- a/src/newsreader/static/icons/dinner.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/direction-ltr.svg b/src/newsreader/static/icons/direction-ltr.svg deleted file mode 100755 index 827ada0..0000000 --- a/src/newsreader/static/icons/direction-ltr.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/direction-rtl.svg b/src/newsreader/static/icons/direction-rtl.svg deleted file mode 100755 index 47ce7d4..0000000 --- a/src/newsreader/static/icons/direction-rtl.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/download.svg b/src/newsreader/static/icons/download.svg deleted file mode 100755 index 51b561f..0000000 --- a/src/newsreader/static/icons/download.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/drop.svg b/src/newsreader/static/icons/drop.svg deleted file mode 100755 index d726f67..0000000 --- a/src/newsreader/static/icons/drop.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/earth.svg b/src/newsreader/static/icons/earth.svg deleted file mode 100755 index 411b22c..0000000 --- a/src/newsreader/static/icons/earth.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/enter-down.svg b/src/newsreader/static/icons/enter-down.svg deleted file mode 100755 index 85794a1..0000000 --- a/src/newsreader/static/icons/enter-down.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/enter.svg b/src/newsreader/static/icons/enter.svg deleted file mode 100755 index 1130e28..0000000 --- a/src/newsreader/static/icons/enter.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/envelope.svg b/src/newsreader/static/icons/envelope.svg deleted file mode 100755 index f030873..0000000 --- a/src/newsreader/static/icons/envelope.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/exit-up.svg b/src/newsreader/static/icons/exit-up.svg deleted file mode 100755 index c67b28c..0000000 --- a/src/newsreader/static/icons/exit-up.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/exit.svg b/src/newsreader/static/icons/exit.svg deleted file mode 100755 index b54f4b5..0000000 --- a/src/newsreader/static/icons/exit.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/eye.svg b/src/newsreader/static/icons/eye.svg deleted file mode 100755 index f67edba..0000000 --- a/src/newsreader/static/icons/eye.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/favicon-placeholder.svg b/src/newsreader/static/icons/favicon-placeholder.svg deleted file mode 100644 index 2dac325..0000000 --- a/src/newsreader/static/icons/favicon-placeholder.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/src/newsreader/static/icons/file-add.svg b/src/newsreader/static/icons/file-add.svg deleted file mode 100755 index 5f0b06d..0000000 --- a/src/newsreader/static/icons/file-add.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/file-empty.svg b/src/newsreader/static/icons/file-empty.svg deleted file mode 100755 index c203b20..0000000 --- a/src/newsreader/static/icons/file-empty.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/film-play.svg b/src/newsreader/static/icons/film-play.svg deleted file mode 100755 index d2a9db0..0000000 --- a/src/newsreader/static/icons/film-play.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/flag.svg b/src/newsreader/static/icons/flag.svg deleted file mode 100755 index 2f91d68..0000000 --- a/src/newsreader/static/icons/flag.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/frame-contract.svg b/src/newsreader/static/icons/frame-contract.svg deleted file mode 100755 index 3a70458..0000000 --- a/src/newsreader/static/icons/frame-contract.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/src/newsreader/static/icons/frame-expand.svg b/src/newsreader/static/icons/frame-expand.svg deleted file mode 100755 index 40f6af0..0000000 --- a/src/newsreader/static/icons/frame-expand.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/src/newsreader/static/icons/funnel.svg b/src/newsreader/static/icons/funnel.svg deleted file mode 100755 index d2688f7..0000000 --- a/src/newsreader/static/icons/funnel.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/gift.svg b/src/newsreader/static/icons/gift.svg deleted file mode 100755 index 72c0bdc..0000000 --- a/src/newsreader/static/icons/gift.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/graduation-hat.svg b/src/newsreader/static/icons/graduation-hat.svg deleted file mode 100755 index abb6099..0000000 --- a/src/newsreader/static/icons/graduation-hat.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/hand.svg b/src/newsreader/static/icons/hand.svg deleted file mode 100755 index 1eacb25..0000000 --- a/src/newsreader/static/icons/hand.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/heart-pulse.svg b/src/newsreader/static/icons/heart-pulse.svg deleted file mode 100755 index 62e0c08..0000000 --- a/src/newsreader/static/icons/heart-pulse.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/newsreader/static/icons/heart.svg b/src/newsreader/static/icons/heart.svg deleted file mode 100755 index 660600b..0000000 --- a/src/newsreader/static/icons/heart.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/highlight.svg b/src/newsreader/static/icons/highlight.svg deleted file mode 100755 index eb706fa..0000000 --- a/src/newsreader/static/icons/highlight.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/history.svg b/src/newsreader/static/icons/history.svg deleted file mode 100755 index 4acfb22..0000000 --- a/src/newsreader/static/icons/history.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/home.svg b/src/newsreader/static/icons/home.svg deleted file mode 100755 index c259dc3..0000000 --- a/src/newsreader/static/icons/home.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/hourglass.svg b/src/newsreader/static/icons/hourglass.svg deleted file mode 100755 index 0e72fba..0000000 --- a/src/newsreader/static/icons/hourglass.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/inbox.svg b/src/newsreader/static/icons/inbox.svg deleted file mode 100755 index 2e0a9f8..0000000 --- a/src/newsreader/static/icons/inbox.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/indent-decrease.svg b/src/newsreader/static/icons/indent-decrease.svg deleted file mode 100755 index 9443dcf..0000000 --- a/src/newsreader/static/icons/indent-decrease.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/src/newsreader/static/icons/indent-increase.svg b/src/newsreader/static/icons/indent-increase.svg deleted file mode 100755 index 25666f4..0000000 --- a/src/newsreader/static/icons/indent-increase.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/src/newsreader/static/icons/italic.svg b/src/newsreader/static/icons/italic.svg deleted file mode 100755 index 6ddde14..0000000 --- a/src/newsreader/static/icons/italic.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/keyboard.svg b/src/newsreader/static/icons/keyboard.svg deleted file mode 100755 index ae51d9c..0000000 --- a/src/newsreader/static/icons/keyboard.svg +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/newsreader/static/icons/laptop-phone.svg b/src/newsreader/static/icons/laptop-phone.svg deleted file mode 100755 index e67b5b3..0000000 --- a/src/newsreader/static/icons/laptop-phone.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/src/newsreader/static/icons/laptop.svg b/src/newsreader/static/icons/laptop.svg deleted file mode 100755 index 51fdb07..0000000 --- a/src/newsreader/static/icons/laptop.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/layers.svg b/src/newsreader/static/icons/layers.svg deleted file mode 100755 index 8cec392..0000000 --- a/src/newsreader/static/icons/layers.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/newsreader/static/icons/leaf.svg b/src/newsreader/static/icons/leaf.svg deleted file mode 100755 index 428a264..0000000 --- a/src/newsreader/static/icons/leaf.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/license.svg b/src/newsreader/static/icons/license.svg deleted file mode 100755 index cf95671..0000000 --- a/src/newsreader/static/icons/license.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/src/newsreader/static/icons/lighter.svg b/src/newsreader/static/icons/lighter.svg deleted file mode 100755 index 09c5e0c..0000000 --- a/src/newsreader/static/icons/lighter.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/line-spacing.svg b/src/newsreader/static/icons/line-spacing.svg deleted file mode 100755 index 8cf8f24..0000000 --- a/src/newsreader/static/icons/line-spacing.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/newsreader/static/icons/linearicons.svg b/src/newsreader/static/icons/linearicons.svg deleted file mode 100755 index bbf2a26..0000000 --- a/src/newsreader/static/icons/linearicons.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/link.svg b/src/newsreader/static/icons/link.svg deleted file mode 100644 index 7bb4a0e..0000000 --- a/src/newsreader/static/icons/link.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/list.svg b/src/newsreader/static/icons/list.svg deleted file mode 100755 index 6255ad9..0000000 --- a/src/newsreader/static/icons/list.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/src/newsreader/static/icons/location.svg b/src/newsreader/static/icons/location.svg deleted file mode 100755 index 272c1d9..0000000 --- a/src/newsreader/static/icons/location.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/lock.svg b/src/newsreader/static/icons/lock.svg deleted file mode 100755 index 76259cf..0000000 --- a/src/newsreader/static/icons/lock.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/magic-wand.svg b/src/newsreader/static/icons/magic-wand.svg deleted file mode 100755 index 55753d8..0000000 --- a/src/newsreader/static/icons/magic-wand.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/newsreader/static/icons/magnifier.svg b/src/newsreader/static/icons/magnifier.svg deleted file mode 100755 index 9c26539..0000000 --- a/src/newsreader/static/icons/magnifier.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/map-marker.svg b/src/newsreader/static/icons/map-marker.svg deleted file mode 100755 index 3a20637..0000000 --- a/src/newsreader/static/icons/map-marker.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/map.svg b/src/newsreader/static/icons/map.svg deleted file mode 100755 index 319e300..0000000 --- a/src/newsreader/static/icons/map.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/menu-circle.svg b/src/newsreader/static/icons/menu-circle.svg deleted file mode 100755 index f3544eb..0000000 --- a/src/newsreader/static/icons/menu-circle.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/src/newsreader/static/icons/menu.svg b/src/newsreader/static/icons/menu.svg deleted file mode 100755 index e0952e0..0000000 --- a/src/newsreader/static/icons/menu.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/newsreader/static/icons/mic.svg b/src/newsreader/static/icons/mic.svg deleted file mode 100755 index 9c08d2d..0000000 --- a/src/newsreader/static/icons/mic.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/moon.svg b/src/newsreader/static/icons/moon.svg deleted file mode 100755 index 3f150c6..0000000 --- a/src/newsreader/static/icons/moon.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/move.svg b/src/newsreader/static/icons/move.svg deleted file mode 100755 index 86c223d..0000000 --- a/src/newsreader/static/icons/move.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/music-note.svg b/src/newsreader/static/icons/music-note.svg deleted file mode 100755 index a4ab001..0000000 --- a/src/newsreader/static/icons/music-note.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/mustache.svg b/src/newsreader/static/icons/mustache.svg deleted file mode 100755 index 8c12f70..0000000 --- a/src/newsreader/static/icons/mustache.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/src/newsreader/static/icons/neutral.svg b/src/newsreader/static/icons/neutral.svg deleted file mode 100755 index 4f55a69..0000000 --- a/src/newsreader/static/icons/neutral.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/src/newsreader/static/icons/page-break.svg b/src/newsreader/static/icons/page-break.svg deleted file mode 100755 index 493c248..0000000 --- a/src/newsreader/static/icons/page-break.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/src/newsreader/static/icons/paperclip.svg b/src/newsreader/static/icons/paperclip.svg deleted file mode 100755 index 2afa342..0000000 --- a/src/newsreader/static/icons/paperclip.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/paw.svg b/src/newsreader/static/icons/paw.svg deleted file mode 100755 index 4170890..0000000 --- a/src/newsreader/static/icons/paw.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/newsreader/static/icons/pencil.svg b/src/newsreader/static/icons/pencil.svg deleted file mode 100755 index fb618b1..0000000 --- a/src/newsreader/static/icons/pencil.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/phone-handset.svg b/src/newsreader/static/icons/phone-handset.svg deleted file mode 100755 index cdadbe5..0000000 --- a/src/newsreader/static/icons/phone-handset.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/phone.svg b/src/newsreader/static/icons/phone.svg deleted file mode 100755 index 2883bc8..0000000 --- a/src/newsreader/static/icons/phone.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/newsreader/static/icons/picture.svg b/src/newsreader/static/icons/picture.svg deleted file mode 100755 index c927a37..0000000 --- a/src/newsreader/static/icons/picture.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/newsreader/static/icons/pie-chart.svg b/src/newsreader/static/icons/pie-chart.svg deleted file mode 100755 index a02c1a5..0000000 --- a/src/newsreader/static/icons/pie-chart.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/pilcrow.svg b/src/newsreader/static/icons/pilcrow.svg deleted file mode 100755 index 1a61c76..0000000 --- a/src/newsreader/static/icons/pilcrow.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/plus-circle.svg b/src/newsreader/static/icons/plus-circle.svg deleted file mode 100755 index a0cf6fa..0000000 --- a/src/newsreader/static/icons/plus-circle.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/pointer-down.svg b/src/newsreader/static/icons/pointer-down.svg deleted file mode 100755 index e2321c6..0000000 --- a/src/newsreader/static/icons/pointer-down.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/pointer-left.svg b/src/newsreader/static/icons/pointer-left.svg deleted file mode 100755 index e3aa356..0000000 --- a/src/newsreader/static/icons/pointer-left.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/pointer-right.svg b/src/newsreader/static/icons/pointer-right.svg deleted file mode 100755 index 011ffba..0000000 --- a/src/newsreader/static/icons/pointer-right.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/pointer-up.svg b/src/newsreader/static/icons/pointer-up.svg deleted file mode 100755 index 08f82a1..0000000 --- a/src/newsreader/static/icons/pointer-up.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/poop.svg b/src/newsreader/static/icons/poop.svg deleted file mode 100755 index 570fade..0000000 --- a/src/newsreader/static/icons/poop.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/power-switch.svg b/src/newsreader/static/icons/power-switch.svg deleted file mode 100755 index 9f47372..0000000 --- a/src/newsreader/static/icons/power-switch.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/printer.svg b/src/newsreader/static/icons/printer.svg deleted file mode 100755 index d338626..0000000 --- a/src/newsreader/static/icons/printer.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/newsreader/static/icons/pushpin.svg b/src/newsreader/static/icons/pushpin.svg deleted file mode 100755 index d88009d..0000000 --- a/src/newsreader/static/icons/pushpin.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/question-circle.svg b/src/newsreader/static/icons/question-circle.svg deleted file mode 100755 index 45e5929..0000000 --- a/src/newsreader/static/icons/question-circle.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/newsreader/static/icons/redo.svg b/src/newsreader/static/icons/redo.svg deleted file mode 100755 index ec68693..0000000 --- a/src/newsreader/static/icons/redo.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/rocket.svg b/src/newsreader/static/icons/rocket.svg deleted file mode 100755 index 552cbcc..0000000 --- a/src/newsreader/static/icons/rocket.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/newsreader/static/icons/sad.svg b/src/newsreader/static/icons/sad.svg deleted file mode 100755 index ed63b85..0000000 --- a/src/newsreader/static/icons/sad.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/src/newsreader/static/icons/screen.svg b/src/newsreader/static/icons/screen.svg deleted file mode 100755 index 057f0d9..0000000 --- a/src/newsreader/static/icons/screen.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/select.svg b/src/newsreader/static/icons/select.svg deleted file mode 100755 index 3e8cfee..0000000 --- a/src/newsreader/static/icons/select.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/shirt.svg b/src/newsreader/static/icons/shirt.svg deleted file mode 100755 index f6dd52d..0000000 --- a/src/newsreader/static/icons/shirt.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/smartphone.svg b/src/newsreader/static/icons/smartphone.svg deleted file mode 100755 index 779f7d2..0000000 --- a/src/newsreader/static/icons/smartphone.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/newsreader/static/icons/smile.svg b/src/newsreader/static/icons/smile.svg deleted file mode 100755 index a1a0a0a..0000000 --- a/src/newsreader/static/icons/smile.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/src/newsreader/static/icons/sort-alpha-asc.svg b/src/newsreader/static/icons/sort-alpha-asc.svg deleted file mode 100755 index 56b8d3f..0000000 --- a/src/newsreader/static/icons/sort-alpha-asc.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/newsreader/static/icons/sort-amount-asc.svg b/src/newsreader/static/icons/sort-amount-asc.svg deleted file mode 100755 index d24c0e4..0000000 --- a/src/newsreader/static/icons/sort-amount-asc.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/newsreader/static/icons/spell-check.svg b/src/newsreader/static/icons/spell-check.svg deleted file mode 100755 index 1c4875c..0000000 --- a/src/newsreader/static/icons/spell-check.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/src/newsreader/static/icons/star-empty.svg b/src/newsreader/static/icons/star-empty.svg deleted file mode 100755 index fd5098c..0000000 --- a/src/newsreader/static/icons/star-empty.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/src/newsreader/static/icons/star-half.svg b/src/newsreader/static/icons/star-half.svg deleted file mode 100755 index c48aa79..0000000 --- a/src/newsreader/static/icons/star-half.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/newsreader/static/icons/star.svg b/src/newsreader/static/icons/star.svg deleted file mode 100755 index 3302123..0000000 --- a/src/newsreader/static/icons/star.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/store.svg b/src/newsreader/static/icons/store.svg deleted file mode 100755 index 9fce882..0000000 --- a/src/newsreader/static/icons/store.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/newsreader/static/icons/strikethrough.svg b/src/newsreader/static/icons/strikethrough.svg deleted file mode 100755 index 825d1d0..0000000 --- a/src/newsreader/static/icons/strikethrough.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/newsreader/static/icons/sun.svg b/src/newsreader/static/icons/sun.svg deleted file mode 100755 index b9d9038..0000000 --- a/src/newsreader/static/icons/sun.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/src/newsreader/static/icons/sync.svg b/src/newsreader/static/icons/sync.svg deleted file mode 100755 index 982223f..0000000 --- a/src/newsreader/static/icons/sync.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/tablet.svg b/src/newsreader/static/icons/tablet.svg deleted file mode 100755 index 8554d69..0000000 --- a/src/newsreader/static/icons/tablet.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/newsreader/static/icons/tag.svg b/src/newsreader/static/icons/tag.svg deleted file mode 100755 index f2a207b..0000000 --- a/src/newsreader/static/icons/tag.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/text-align-center.svg b/src/newsreader/static/icons/text-align-center.svg deleted file mode 100755 index 4ca60a9..0000000 --- a/src/newsreader/static/icons/text-align-center.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/newsreader/static/icons/text-align-justify.svg b/src/newsreader/static/icons/text-align-justify.svg deleted file mode 100755 index 814e51e..0000000 --- a/src/newsreader/static/icons/text-align-justify.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/newsreader/static/icons/text-align-left.svg b/src/newsreader/static/icons/text-align-left.svg deleted file mode 100755 index ee71585..0000000 --- a/src/newsreader/static/icons/text-align-left.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/newsreader/static/icons/text-align-right.svg b/src/newsreader/static/icons/text-align-right.svg deleted file mode 100755 index 4884054..0000000 --- a/src/newsreader/static/icons/text-align-right.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/newsreader/static/icons/text-format-remove.svg b/src/newsreader/static/icons/text-format-remove.svg deleted file mode 100755 index f472c8c..0000000 --- a/src/newsreader/static/icons/text-format-remove.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/newsreader/static/icons/text-format.svg b/src/newsreader/static/icons/text-format.svg deleted file mode 100755 index 5fe551c..0000000 --- a/src/newsreader/static/icons/text-format.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/text-size.svg b/src/newsreader/static/icons/text-size.svg deleted file mode 100755 index aef49f1..0000000 --- a/src/newsreader/static/icons/text-size.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/thumbs-down.svg b/src/newsreader/static/icons/thumbs-down.svg deleted file mode 100755 index efe684e..0000000 --- a/src/newsreader/static/icons/thumbs-down.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/thumbs-up.svg b/src/newsreader/static/icons/thumbs-up.svg deleted file mode 100755 index bcfaec2..0000000 --- a/src/newsreader/static/icons/thumbs-up.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/times.svg b/src/newsreader/static/icons/times.svg deleted file mode 100644 index 571a32a..0000000 --- a/src/newsreader/static/icons/times.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/newsreader/static/icons/train.svg b/src/newsreader/static/icons/train.svg deleted file mode 100755 index efdeef9..0000000 --- a/src/newsreader/static/icons/train.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/src/newsreader/static/icons/trash.svg b/src/newsreader/static/icons/trash.svg deleted file mode 100755 index 4b35080..0000000 --- a/src/newsreader/static/icons/trash.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/src/newsreader/static/icons/underline.svg b/src/newsreader/static/icons/underline.svg deleted file mode 100755 index 0d440e5..0000000 --- a/src/newsreader/static/icons/underline.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/undo.svg b/src/newsreader/static/icons/undo.svg deleted file mode 100755 index ba812e0..0000000 --- a/src/newsreader/static/icons/undo.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/unlink.svg b/src/newsreader/static/icons/unlink.svg deleted file mode 100755 index 2c176da..0000000 --- a/src/newsreader/static/icons/unlink.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/src/newsreader/static/icons/upload.svg b/src/newsreader/static/icons/upload.svg deleted file mode 100755 index 12ac621..0000000 --- a/src/newsreader/static/icons/upload.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/user.svg b/src/newsreader/static/icons/user.svg deleted file mode 100755 index 5931e5f..0000000 --- a/src/newsreader/static/icons/user.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/users.svg b/src/newsreader/static/icons/users.svg deleted file mode 100755 index 743ef13..0000000 --- a/src/newsreader/static/icons/users.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/src/newsreader/static/icons/volume-high.svg b/src/newsreader/static/icons/volume-high.svg deleted file mode 100755 index 7e891c2..0000000 --- a/src/newsreader/static/icons/volume-high.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/src/newsreader/static/icons/volume-low.svg b/src/newsreader/static/icons/volume-low.svg deleted file mode 100755 index 4872d97..0000000 --- a/src/newsreader/static/icons/volume-low.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/static/icons/volume-medium.svg b/src/newsreader/static/icons/volume-medium.svg deleted file mode 100755 index 71b2fca..0000000 --- a/src/newsreader/static/icons/volume-medium.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/newsreader/static/icons/volume.svg b/src/newsreader/static/icons/volume.svg deleted file mode 100755 index edb1f6e..0000000 --- a/src/newsreader/static/icons/volume.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/newsreader/static/icons/warning.svg b/src/newsreader/static/icons/warning.svg deleted file mode 100755 index 21f9e68..0000000 --- a/src/newsreader/static/icons/warning.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/newsreader/static/icons/wheelchair.svg b/src/newsreader/static/icons/wheelchair.svg deleted file mode 100755 index 25cdac0..0000000 --- a/src/newsreader/static/icons/wheelchair.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/newsreader/templates/base.html b/src/newsreader/templates/base.html index 7f3f560..42d438b 100644 --- a/src/newsreader/templates/base.html +++ b/src/newsreader/templates/base.html @@ -4,7 +4,7 @@ Newreader - + {% block head %} {% endblock %} diff --git a/webpack.common.babel.js b/webpack.common.babel.js index 0a0602e..2c8471c 100644 --- a/webpack.common.babel.js +++ b/webpack.common.babel.js @@ -7,14 +7,14 @@ export default { main: ['./src/newsreader/js/index.js', './src/newsreader/scss/index.scss'], }, output: { - path: resolve(__dirname, 'src', 'newsreader', 'static', 'js'), - filename: '[name].bundle.js', + path: resolve(__dirname, 'src', 'newsreader', 'static'), + filename: 'js/[name].bundle.js', }, module: { rules: [ { test: /\.(js|jsx)$/, - exclude: /(node_modules|bower_components)/, + exclude: /node_modules/, use: { loader: 'babel-loader' }, }, { @@ -25,9 +25,11 @@ export default { }, plugins: [ new MiniCssExtractPlugin({ - filename: './src/newsreader/static/css/[name].css', + filename: 'css/main.css', allChunks: true, }), - new CleanWebpackPlugin(), + new CleanWebpackPlugin({ + cleanOnceBeforeBuildPatterns: ['**/*', '!favicon.png'], + }), ], }; From e495d7c1887dbfa815be2a012ae7c2b292fe08f8 Mon Sep 17 00:00:00 2001 From: Sonny Date: Mon, 13 Apr 2020 17:05:46 +0200 Subject: [PATCH 074/422] Use poetry for dependency management --- .gitlab-ci.yml | 24 +- Dockerfile | 15 +- docker-compose.yml | 2 +- poetry.lock | 1115 +++++++++++++++++ pyproject.toml | 39 + requirements/base.txt | 21 - requirements/dev.txt | 10 - requirements/gitlab.txt | 1 - requirements/production.txt | 4 - requirements/testing.txt | 4 - src/entrypoint.sh | 4 +- .../accounts/migrations/0001_initial.py | 4 +- .../migrations/0004_auto_20190714_1501.py | 2 +- .../migrations/0002_auto_20190714_1036.py | 4 +- .../migrations/0003_auto_20190714_1417.py | 4 +- .../migrations/0006_auto_20200412_1955.py | 27 + src/newsreader/news/collection/models.py | 7 +- .../news/core/migrations/0001_initial.py | 4 +- .../migrations/0002_auto_20190714_1425.py | 2 +- .../migrations/0005_auto_20200412_1955.py | 27 + src/newsreader/news/core/models.py | 7 +- 21 files changed, 1252 insertions(+), 75 deletions(-) create mode 100644 poetry.lock create mode 100644 pyproject.toml delete mode 100644 requirements/base.txt delete mode 100644 requirements/dev.txt delete mode 100644 requirements/gitlab.txt delete mode 100644 requirements/production.txt delete mode 100644 requirements/testing.txt create mode 100644 src/newsreader/news/collection/migrations/0006_auto_20200412_1955.py create mode 100644 src/newsreader/news/core/migrations/0005_auto_20200412_1955.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 134d7bb..0a89ab2 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -34,13 +34,13 @@ python tests: key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG" paths: - .cache/pip - - env/ + - .venv/ before_script: - - python3 -m venv env - - source env/bin/activate - - pip install -r requirements/gitlab.txt + - pip install poetry + - poetry config virtualenvs.in-project true + - poetry install --no-interaction script: - - python src/manage.py test newsreader + - poetry run src/manage.py test newsreader javascript tests: image: node:12 @@ -76,15 +76,15 @@ python linting: key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG" paths: - .cache/pip - - env/ + - .venv/ before_script: - - python3 -m venv env - - source env/bin/activate - - pip install -r requirements/gitlab.txt + - pip install poetry + - poetry config virtualenvs.in-project true + - poetry install --no-interaction script: - - isort src/ --check-only --recursive - - black src/ --line-length 88 --check - - autoflake src/ --check --recursive --remove-all-unused-imports --ignore-init-module-imports + - poetry run isort src/ --check-only --recursive + - poetry run black src/ --line-length 88 --check + - poetry run autoflake src/ --check --recursive --remove-all-unused-imports --ignore-init-module-imports deploy: stage: deploy diff --git a/Dockerfile b/Dockerfile index ac7ac5b..61ef10b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,11 @@ FROM python:3.7-buster -# Run project binaries from the user's local bin folder -ENV PATH=/home/newsreader/.local/bin:$PATH +RUN pip install poetry -# Set the default shell -RUN useradd -ms /bin/bash newsreader - -RUN mkdir /app WORKDIR /app -RUN chown newsreader:newsreader /app -USER newsreader +COPY poetry.lock pyproject.toml /app/ -# Use a seperate layer for the project requirements -COPY requirements /app/requirements -RUN pip install --user -r requirements/dev.txt +RUN poetry config virtualenvs.create false +RUN poetry install --no-interaction COPY . /app/ diff --git a/docker-compose.yml b/docker-compose.yml index 10c3846..7a39a3f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: celery: build: . container_name: celery - command: celery -A newsreader worker -l INFO --beat --scheduler django --workdir=/app/src/ + command: poetry run celery -A newsreader worker -l INFO --beat --scheduler django --workdir=/app/src/ environment: - POSTGRES_HOST=$POSTGRES_HOST - POSTGRES_NAME=$POSTGRES_NAME diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..cfb606f --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1115 @@ +[[package]] +category = "main" +description = "Low-level AMQP client for Python (fork of amqplib)." +name = "amqp" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "2.5.2" + +[package.dependencies] +vine = ">=1.1.3,<5.0.0a1" + +[[package]] +category = "dev" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +name = "appdirs" +optional = false +python-versions = "*" +version = "1.4.3" + +[[package]] +category = "main" +description = "ASGI specs, helper code, and adapters" +name = "asgiref" +optional = false +python-versions = ">=3.5" +version = "3.2.7" + +[package.extras] +tests = ["pytest (>=4.3.0,<4.4.0)", "pytest-asyncio (>=0.10.0,<0.11.0)"] + +[[package]] +category = "dev" +description = "Classes Without Boilerplate" +name = "attrs" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "19.3.0" + +[package.extras] +azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"] +dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"] +docs = ["sphinx", "zope.interface"] +tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] + +[[package]] +category = "dev" +description = "Removes unused imports and unused variables" +name = "autoflake" +optional = false +python-versions = "*" +version = "1.3.1" + +[package.dependencies] +pyflakes = ">=1.1.0" + +[[package]] +category = "main" +description = "Screen-scraping library" +name = "beautifulsoup4" +optional = false +python-versions = "*" +version = "4.9.0" + +[package.dependencies] +soupsieve = [">1.2", "<2.0"] + +[package.extras] +html5lib = ["html5lib"] +lxml = ["lxml"] + +[[package]] +category = "main" +description = "Python multiprocessing fork with improvements and bugfixes" +name = "billiard" +optional = false +python-versions = "*" +version = "3.6.3.0" + +[[package]] +category = "dev" +description = "The uncompromising code formatter." +name = "black" +optional = false +python-versions = ">=3.6" +version = "19.3b0" + +[package.dependencies] +appdirs = "*" +attrs = ">=18.1.0" +click = ">=6.5" +toml = ">=0.9.4" + +[package.extras] +d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] + +[[package]] +category = "main" +description = "An easy safelist-based HTML-sanitizing tool." +name = "bleach" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "3.1.4" + +[package.dependencies] +six = ">=1.9.0" +webencodings = "*" + +[[package]] +category = "main" +description = "Distributed Task Queue." +name = "celery" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*," +version = "4.4.2" + +[package.dependencies] +billiard = ">=3.6.3.0,<4.0" +kombu = ">=4.6.8,<4.7" +pytz = ">0.0-dev" +vine = "1.3.0" + +[package.extras] +arangodb = ["pyArango (>=1.3.2)"] +auth = ["cryptography"] +azureblockblob = ["azure-storage (0.36.0)", "azure-common (1.1.5)", "azure-storage-common (1.1.0)"] +brotli = ["brotli (>=1.0.0)", "brotlipy (>=0.7.0)"] +cassandra = ["cassandra-driver"] +consul = ["python-consul"] +cosmosdbsql = ["pydocumentdb (2.3.2)"] +couchbase = ["couchbase", "couchbase-cffi"] +couchdb = ["pycouchdb"] +django = ["Django (>=1.11)"] +dynamodb = ["boto3 (>=1.9.178)"] +elasticsearch = ["elasticsearch"] +eventlet = ["eventlet (>=0.24.1)"] +gevent = ["gevent"] +librabbitmq = ["librabbitmq (>=1.5.0)"] +lzma = ["backports.lzma"] +memcache = ["pylibmc"] +mongodb = ["pymongo (>=3.3.0)"] +msgpack = ["msgpack"] +pymemcache = ["python-memcached"] +pyro = ["pyro4"] +redis = ["redis (>=3.2.0)"] +riak = ["riak (>=2.0)"] +s3 = ["boto3 (>=1.9.125)"] +slmq = ["softlayer-messaging (>=1.0.3)"] +solar = ["ephem"] +sqlalchemy = ["sqlalchemy"] +sqs = ["boto3 (>=1.9.125)", "pycurl (7.43.0.2)"] +tblib = ["tblib (>=1.3.0)", "tblib (>=1.5.0)"] +yaml = ["PyYAML (>=3.10)"] +zookeeper = ["kazoo (>=1.3.1)"] +zstd = ["zstandard"] + +[[package]] +category = "main" +description = "Python package for providing Mozilla's CA Bundle." +name = "certifi" +optional = false +python-versions = "*" +version = "2020.4.5.1" + +[[package]] +category = "main" +description = "Universal encoding detector for Python 2 and 3" +name = "chardet" +optional = false +python-versions = "*" +version = "3.0.4" + +[[package]] +category = "dev" +description = "Composable command line interface toolkit" +name = "click" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "7.1.1" + +[[package]] +category = "main" +description = "Python client library for Core API." +name = "coreapi" +optional = false +python-versions = "*" +version = "2.3.3" + +[package.dependencies] +coreschema = "*" +itypes = "*" +requests = "*" +uritemplate = "*" + +[[package]] +category = "main" +description = "Core Schema." +name = "coreschema" +optional = false +python-versions = "*" +version = "0.0.4" + +[package.dependencies] +jinja2 = "*" + +[[package]] +category = "main" +description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." +name = "django" +optional = false +python-versions = ">=3.6" +version = "3.0.5" + +[package.dependencies] +asgiref = ">=3.2,<4.0" +pytz = "*" +sqlparse = ">=0.2.2" + +[package.extras] +argon2 = ["argon2-cffi (>=16.1.0)"] +bcrypt = ["bcrypt"] + +[[package]] +category = "main" +description = "A helper class for handling configuration defaults of packaged apps gracefully." +name = "django-appconf" +optional = false +python-versions = "*" +version = "1.0.4" + +[package.dependencies] +django = "*" + +[[package]] +category = "main" +description = "Keep track of failed login attempts in Django-powered sites." +name = "django-axes" +optional = false +python-versions = "~=3.6" +version = "5.3.1" + +[package.dependencies] +django = ">=1.11" +django-appconf = ">=1.0.3" +django-ipware = ">=2.0.2" + +[[package]] +category = "main" +description = "Database-backed Periodic Tasks." +name = "django-celery-beat" +optional = false +python-versions = "*" +version = "2.0.0" + +[package.dependencies] +Django = ">=1.11.17" +celery = "*" +django-timezone-field = ">=4.0,<5.0" +python-crontab = ">=2.3.4" + +[[package]] +category = "dev" +description = "A configurable set of panels that display various debug information about the current request/response." +name = "django-debug-toolbar" +optional = false +python-versions = ">=3.5" +version = "2.2" + +[package.dependencies] +Django = ">=1.11" +sqlparse = ">=0.2.0" + +[[package]] +category = "dev" +description = "Extensions for Django" +name = "django-extensions" +optional = false +python-versions = "*" +version = "2.2.9" + +[package.dependencies] +six = ">=1.2" + +[[package]] +category = "main" +description = "A Django utility application that returns client's real IP address" +name = "django-ipware" +optional = false +python-versions = "*" +version = "2.1.0" + +[[package]] +category = "main" +description = "An extensible user-registration application for Django" +name = "django-registration-redux" +optional = false +python-versions = "*" +version = "2.7" + +[[package]] +category = "main" +description = "A Django app providing database and form fields for pytz timezone objects." +name = "django-timezone-field" +optional = false +python-versions = ">=3.5" +version = "4.0" + +[package.dependencies] +django = ">=2.2" +pytz = "*" + +[[package]] +category = "main" +description = "Web APIs for Django, made easy." +name = "djangorestframework" +optional = false +python-versions = ">=3.5" +version = "3.11.0" + +[package.dependencies] +django = ">=1.11" + +[[package]] +category = "main" +description = "Automated generation of real Swagger/OpenAPI 2.0 schemas from Django Rest Framework code." +name = "drf-yasg" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "1.17.1" + +[package.dependencies] +Django = ">=1.11.7" +coreapi = ">=2.3.3" +coreschema = ">=0.0.4" +djangorestframework = ">=3.8" +inflection = ">=0.3.1" +packaging = "*" +"ruamel.yaml" = ">=0.15.34" +six = ">=1.10.0" +uritemplate = ">=3.0.0" + +[package.extras] +validation = ["swagger-spec-validator (>=2.1.0)"] + +[[package]] +category = "dev" +description = "A versatile test fixtures replacement based on thoughtbot's factory_bot for Ruby." +name = "factory-boy" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.12.0" + +[package.dependencies] +Faker = ">=0.7.0" + +[[package]] +category = "dev" +description = "Faker is a Python package that generates fake data for you." +name = "faker" +optional = false +python-versions = ">=3.4" +version = "4.0.2" + +[package.dependencies] +python-dateutil = ">=2.4" +text-unidecode = "1.3" + +[[package]] +category = "main" +description = "Universal feed parser, handles RSS 0.9x, RSS 1.0, RSS 2.0, CDF, Atom 0.3, and Atom 1.0 feeds" +name = "feedparser" +optional = false +python-versions = "*" +version = "5.2.1" + +[[package]] +category = "dev" +description = "Let your Python tests travel through time" +name = "freezegun" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "0.3.15" + +[package.dependencies] +python-dateutil = ">=1.0,<2.0 || >2.0" +six = "*" + +[[package]] +category = "main" +description = "WSGI HTTP Server for UNIX" +name = "gunicorn" +optional = false +python-versions = ">=3.4" +version = "20.0.4" + +[package.dependencies] +setuptools = ">=3.0" + +[package.extras] +eventlet = ["eventlet (>=0.9.7)"] +gevent = ["gevent (>=0.13)"] +setproctitle = ["setproctitle"] +tornado = ["tornado (>=0.2)"] + +[[package]] +category = "main" +description = "Internationalized Domain Names in Applications (IDNA)" +name = "idna" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.9" + +[[package]] +category = "main" +description = "Read metadata from Python packages" +marker = "python_version < \"3.8\"" +name = "importlib-metadata" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +version = "1.6.0" + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["sphinx", "rst.linker"] +testing = ["packaging", "importlib-resources"] + +[[package]] +category = "main" +description = "A port of Ruby on Rails inflector to Python" +name = "inflection" +optional = false +python-versions = ">=3.5" +version = "0.4.0" + +[[package]] +category = "dev" +description = "A Python utility / library to sort Python imports." +name = "isort" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "4.3.21" + +[package.extras] +pipfile = ["pipreqs", "requirementslib"] +pyproject = ["toml"] +requirements = ["pipreqs", "pip-api"] +xdg_home = ["appdirs (>=1.4.0)"] + +[[package]] +category = "main" +description = "Simple immutable types for python." +name = "itypes" +optional = false +python-versions = "*" +version = "1.1.0" + +[[package]] +category = "main" +description = "A very fast and expressive template engine." +name = "jinja2" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "2.11.1" + +[package.dependencies] +MarkupSafe = ">=0.23" + +[package.extras] +i18n = ["Babel (>=0.8)"] + +[[package]] +category = "main" +description = "Messaging library for Python." +name = "kombu" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "4.6.8" + +[package.dependencies] +amqp = ">=2.5.2,<2.6" + +[package.dependencies.importlib-metadata] +python = "<3.8" +version = ">=0.18" + +[package.extras] +azureservicebus = ["azure-servicebus (>=0.21.1)"] +azurestoragequeues = ["azure-storage-queue"] +consul = ["python-consul (>=0.6.0)"] +librabbitmq = ["librabbitmq (>=1.5.2)"] +mongodb = ["pymongo (>=3.3.0)"] +msgpack = ["msgpack"] +pyro = ["pyro4"] +qpid = ["qpid-python (>=0.26)", "qpid-tools (>=0.26)"] +redis = ["redis (>=3.3.11)"] +slmq = ["softlayer-messaging (>=1.0.3)"] +sqlalchemy = ["sqlalchemy"] +sqs = ["boto3 (>=1.4.4)", "pycurl (7.43.0.2)"] +yaml = ["PyYAML (>=3.10)"] +zookeeper = ["kazoo (>=1.3.1)"] + +[[package]] +category = "main" +description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." +name = "lxml" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*" +version = "4.5.0" + +[package.extras] +cssselect = ["cssselect (>=0.7)"] +html5 = ["html5lib"] +htmlsoup = ["beautifulsoup4"] +source = ["Cython (>=0.29.7)"] + +[[package]] +category = "main" +description = "Safely add untrusted strings to HTML/XML markup." +name = "markupsafe" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" +version = "1.1.1" + +[[package]] +category = "main" +description = "Core utilities for Python packages" +name = "packaging" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "20.3" + +[package.dependencies] +pyparsing = ">=2.0.2" +six = "*" + +[[package]] +category = "main" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +name = "psycopg2-binary" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" +version = "2.8.5" + +[[package]] +category = "dev" +description = "passive checker of Python programs" +name = "pyflakes" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.2.0" + +[[package]] +category = "main" +description = "Python parsing module" +name = "pyparsing" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +version = "2.4.7" + +[[package]] +category = "main" +description = "Python Crontab API" +name = "python-crontab" +optional = false +python-versions = "*" +version = "2.4.1" + +[package.dependencies] +python-dateutil = "*" + +[package.extras] +cron-description = ["cron-descriptor"] +cron-schedule = ["croniter"] + +[[package]] +category = "main" +description = "Extensions to the standard Python datetime module" +name = "python-dateutil" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +version = "2.8.1" + +[package.dependencies] +six = ">=1.5" + +[[package]] +category = "main" +description = "Add .env support to your django/flask apps in development and deployments" +name = "python-dotenv" +optional = false +python-versions = "*" +version = "0.12.0" + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +category = "main" +description = "Pure python memcached client" +name = "python-memcached" +optional = false +python-versions = "*" +version = "1.59" + +[package.dependencies] +six = ">=1.4.0" + +[[package]] +category = "main" +description = "World timezone definitions, modern and historical" +name = "pytz" +optional = false +python-versions = "*" +version = "2019.3" + +[[package]] +category = "main" +description = "Python HTTP for Humans." +name = "requests" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "2.23.0" + +[package.dependencies] +certifi = ">=2017.4.17" +chardet = ">=3.0.2,<4" +idna = ">=2.5,<3" +urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" + +[package.extras] +security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] +socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] + +[[package]] +category = "main" +description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" +name = "ruamel.yaml" +optional = false +python-versions = "*" +version = "0.16.10" + +[package.dependencies] +[package.dependencies."ruamel.yaml.clib"] +python = "<3.9" +version = ">=0.1.2" + +[package.extras] +docs = ["ryd"] +jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] + +[[package]] +category = "main" +description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" +marker = "platform_python_implementation == \"CPython\" and python_version < \"3.9\"" +name = "ruamel.yaml.clib" +optional = false +python-versions = "*" +version = "0.2.0" + +[[package]] +category = "main" +description = "Python 2 and 3 compatibility utilities" +name = "six" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +version = "1.14.0" + +[[package]] +category = "main" +description = "A modern CSS selector implementation for Beautiful Soup." +name = "soupsieve" +optional = false +python-versions = "*" +version = "1.9.5" + +[[package]] +category = "main" +description = "Non-validating SQL parser" +name = "sqlparse" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "0.3.1" + +[[package]] +category = "dev" +description = "Traceback serialization library." +name = "tblib" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "1.6.0" + +[[package]] +category = "dev" +description = "The most basic Text::Unidecode port" +name = "text-unidecode" +optional = false +python-versions = "*" +version = "1.3" + +[[package]] +category = "dev" +description = "Python Library for Tom's Obvious, Minimal Language" +name = "toml" +optional = false +python-versions = "*" +version = "0.10.0" + +[[package]] +category = "main" +description = "URI templates" +name = "uritemplate" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "3.0.1" + +[[package]] +category = "main" +description = "HTTP library with thread-safe connection pooling, file post, and more." +name = "urllib3" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" +version = "1.25.8" + +[package.extras] +brotli = ["brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] + +[[package]] +category = "main" +description = "Promises, promises, promises." +name = "vine" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.3.0" + +[[package]] +category = "main" +description = "Character encoding aliases for legacy web content" +name = "webencodings" +optional = false +python-versions = "*" +version = "0.5.1" + +[[package]] +category = "main" +description = "Backport of pathlib-compatible object wrapper for zip files" +marker = "python_version < \"3.8\"" +name = "zipp" +optional = false +python-versions = ">=3.6" +version = "3.1.0" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] +testing = ["jaraco.itertools", "func-timeout"] + +[metadata] +content-hash = "28046079901294125f012386748aa5433bc92b8237514e3f7e1bbceb0258ae44" +python-versions = "^3.7" + +[metadata.files] +amqp = [ + {file = "amqp-2.5.2-py2.py3-none-any.whl", hash = "sha256:6e649ca13a7df3faacdc8bbb280aa9a6602d22fd9d545336077e573a1f4ff3b8"}, + {file = "amqp-2.5.2.tar.gz", hash = "sha256:77f1aef9410698d20eaeac5b73a87817365f457a507d82edf292e12cbb83b08d"}, +] +appdirs = [ + {file = "appdirs-1.4.3-py2.py3-none-any.whl", hash = "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"}, + {file = "appdirs-1.4.3.tar.gz", hash = "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92"}, +] +asgiref = [ + {file = "asgiref-3.2.7-py2.py3-none-any.whl", hash = "sha256:9ca8b952a0a9afa61d30aa6d3d9b570bb3fd6bafcf7ec9e6bed43b936133db1c"}, + {file = "asgiref-3.2.7.tar.gz", hash = "sha256:8036f90603c54e93521e5777b2b9a39ba1bad05773fcf2d208f0299d1df58ce5"}, +] +attrs = [ + {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"}, + {file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"}, +] +autoflake = [ + {file = "autoflake-1.3.1.tar.gz", hash = "sha256:680cb9dade101ed647488238ccb8b8bfb4369b53d58ba2c8cdf7d5d54e01f95b"}, +] +beautifulsoup4 = [ + {file = "beautifulsoup4-4.9.0-py2-none-any.whl", hash = "sha256:a4bbe77fd30670455c5296242967a123ec28c37e9702a8a81bd2f20a4baf0368"}, + {file = "beautifulsoup4-4.9.0-py3-none-any.whl", hash = "sha256:d4e96ac9b0c3a6d3f0caae2e4124e6055c5dcafde8e2f831ff194c104f0775a0"}, + {file = "beautifulsoup4-4.9.0.tar.gz", hash = "sha256:594ca51a10d2b3443cbac41214e12dbb2a1cd57e1a7344659849e2e20ba6a8d8"}, +] +billiard = [ + {file = "billiard-3.6.3.0-py3-none-any.whl", hash = "sha256:bff575450859a6e0fbc2f9877d9b715b0bbc07c3565bb7ed2280526a0cdf5ede"}, + {file = "billiard-3.6.3.0.tar.gz", hash = "sha256:d91725ce6425f33a97dfa72fb6bfef0e47d4652acd98a032bd1a7fbf06d5fa6a"}, +] +black = [ + {file = "black-19.3b0-py36-none-any.whl", hash = "sha256:09a9dcb7c46ed496a9850b76e4e825d6049ecd38b611f1224857a79bd985a8cf"}, + {file = "black-19.3b0.tar.gz", hash = "sha256:68950ffd4d9169716bcb8719a56c07a2f4485354fec061cdd5910aa07369731c"}, +] +bleach = [ + {file = "bleach-3.1.4-py2.py3-none-any.whl", hash = "sha256:cc8da25076a1fe56c3ac63671e2194458e0c4d9c7becfd52ca251650d517903c"}, + {file = "bleach-3.1.4.tar.gz", hash = "sha256:e78e426105ac07026ba098f04de8abe9b6e3e98b5befbf89b51a5ef0a4292b03"}, +] +celery = [ + {file = "celery-4.4.2-py2.py3-none-any.whl", hash = "sha256:5b4b37e276033fe47575107a2775469f0b721646a08c96ec2c61531e4fe45f2a"}, + {file = "celery-4.4.2.tar.gz", hash = "sha256:108a0bf9018a871620936c33a3ee9f6336a89f8ef0a0f567a9001f4aa361415f"}, +] +certifi = [ + {file = "certifi-2020.4.5.1-py2.py3-none-any.whl", hash = "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304"}, + {file = "certifi-2020.4.5.1.tar.gz", hash = "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519"}, +] +chardet = [ + {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, + {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, +] +click = [ + {file = "click-7.1.1-py2.py3-none-any.whl", hash = "sha256:e345d143d80bf5ee7534056164e5e112ea5e22716bbb1ce727941f4c8b471b9a"}, + {file = "click-7.1.1.tar.gz", hash = "sha256:8a18b4ea89d8820c5d0c7da8a64b2c324b4dabb695804dbfea19b9be9d88c0cc"}, +] +coreapi = [ + {file = "coreapi-2.3.3-py2.py3-none-any.whl", hash = "sha256:bf39d118d6d3e171f10df9ede5666f63ad80bba9a29a8ec17726a66cf52ee6f3"}, + {file = "coreapi-2.3.3.tar.gz", hash = "sha256:46145fcc1f7017c076a2ef684969b641d18a2991051fddec9458ad3f78ffc1cb"}, +] +coreschema = [ + {file = "coreschema-0.0.4-py2-none-any.whl", hash = "sha256:5e6ef7bf38c1525d5e55a895934ab4273548629f16aed5c0a6caa74ebf45551f"}, + {file = "coreschema-0.0.4.tar.gz", hash = "sha256:9503506007d482ab0867ba14724b93c18a33b22b6d19fb419ef2d239dd4a1607"}, +] +django = [ + {file = "Django-3.0.5-py3-none-any.whl", hash = "sha256:642d8eceab321ca743ae71e0f985ff8fdca59f07aab3a9fb362c617d23e33a76"}, + {file = "Django-3.0.5.tar.gz", hash = "sha256:d4666c2edefa38c5ede0ec1655424c56dc47ceb04b6d8d62a7eac09db89545c1"}, +] +django-appconf = [ + {file = "django-appconf-1.0.4.tar.gz", hash = "sha256:be58deb54a43d77d2e1621fe59f787681376d3cd0b8bd8e4758ef6c3a6453380"}, + {file = "django_appconf-1.0.4-py2.py3-none-any.whl", hash = "sha256:1b1d0e1069c843ebe8ae5aa48ec52403b1440402b320c3e3a206a0907e97bb06"}, +] +django-axes = [ + {file = "django-axes-5.3.1.tar.gz", hash = "sha256:23eee8297dfcb5aa780e4925f58d723387afe8ecc8fd6a7e9522d26c95c7b880"}, + {file = "django_axes-5.3.1-py3-none-any.whl", hash = "sha256:49fa9736cbbf7d83a61ed57f7b2ebd65f8d3064bb0c45b945bfa7421288031a1"}, +] +django-celery-beat = [ + {file = "django-celery-beat-2.0.0.tar.gz", hash = "sha256:fdf1255eecfbeb770c6521fe3e69989dfc6373cd5a7f0fe62038d37f80f47e48"}, + {file = "django_celery_beat-2.0.0-py2.py3-none-any.whl", hash = "sha256:fe0b2a1b31d4a6234fea4b31986ddfd4644a48fab216ce1843f3ed0ddd2e9097"}, +] +django-debug-toolbar = [ + {file = "django-debug-toolbar-2.2.tar.gz", hash = "sha256:eabbefe89881bbe4ca7c980ff102e3c35c8e8ad6eb725041f538988f2f39a943"}, + {file = "django_debug_toolbar-2.2-py3-none-any.whl", hash = "sha256:ff94725e7aae74b133d0599b9bf89bd4eb8f5d2c964106e61d11750228c8774c"}, +] +django-extensions = [ + {file = "django-extensions-2.2.9.tar.gz", hash = "sha256:2f81b618ba4d1b0e58603e25012e5c74f88a4b706e0022a3b21f24f0322a6ce6"}, + {file = "django_extensions-2.2.9-py2.py3-none-any.whl", hash = "sha256:b19182d101a441fe001c5753553a901e2ef3ff60e8fbbe38881eb4a61fdd17c4"}, +] +django-ipware = [ + {file = "django-ipware-2.1.0.tar.gz", hash = "sha256:a7c7a8fd019dbdc9c357e6e582f65034e897572fc79a7e467674efa8aef9d00b"}, +] +django-registration-redux = [ + {file = "django-registration-redux-2.7.tar.gz", hash = "sha256:1aaf08c9c16b7f185ffa36e7251c7a6149fe953f5af21c4f1e01cbe03902520b"}, + {file = "django_registration_redux-2.7-py2.py3-none-any.whl", hash = "sha256:5998a8dbee2a84d66cd56a61c4fb6b1a5be801b083fb1ef53ba04939d8a44606"}, +] +django-timezone-field = [ + {file = "django-timezone-field-4.0.tar.gz", hash = "sha256:7e3620fe2211c2d372fad54db8f86ff884098d018d56fda4dca5e64929e05ffc"}, + {file = "django_timezone_field-4.0-py3-none-any.whl", hash = "sha256:758b7d41084e9ea2e89e59eb616e9b6326e6fbbf9d14b6ef062d624fe8cc6246"}, +] +djangorestframework = [ + {file = "djangorestframework-3.11.0-py3-none-any.whl", hash = "sha256:05809fc66e1c997fd9a32ea5730d9f4ba28b109b9da71fccfa5ff241201fd0a4"}, + {file = "djangorestframework-3.11.0.tar.gz", hash = "sha256:e782087823c47a26826ee5b6fa0c542968219263fb3976ec3c31edab23a4001f"}, +] +drf-yasg = [ + {file = "drf-yasg-1.17.1.tar.gz", hash = "sha256:5572e9d5baab9f6b49318169df9789f7399d0e3c7bdac8fdb8dfccf1d5d2b1ca"}, + {file = "drf_yasg-1.17.1-py2.py3-none-any.whl", hash = "sha256:7d7af27ad16e18507e9392b2afd6b218fbffc432ec8dbea053099a2241e184ff"}, +] +factory-boy = [ + {file = "factory_boy-2.12.0-py2.py3-none-any.whl", hash = "sha256:728df59b372c9588b83153facf26d3d28947fc750e8e3c95cefa9bed0e6394ee"}, + {file = "factory_boy-2.12.0.tar.gz", hash = "sha256:faf48d608a1735f0d0a3c9cbf536d64f9132b547dae7ba452c4d99a79e84a370"}, +] +faker = [ + {file = "Faker-4.0.2-py3-none-any.whl", hash = "sha256:b89aa33837498498e15c709eb40c31386408a901a53c7a5e12a425737a767976"}, + {file = "Faker-4.0.2.tar.gz", hash = "sha256:2d3f866ef25e1a5af80e7b0ceeacc3c92dec5d0fdbad3e2cb6adf6e60b22188f"}, +] +feedparser = [ + {file = "feedparser-5.2.1.tar.bz2", hash = "sha256:ce875495c90ebd74b179855449040003a1beb40cd13d5f037a0654251e260b02"}, + {file = "feedparser-5.2.1.tar.gz", hash = "sha256:bd030652c2d08532c034c27fcd7c85868e7fa3cb2b17f230a44a6bbc92519bf9"}, + {file = "feedparser-5.2.1.zip", hash = "sha256:cd2485472e41471632ed3029d44033ee420ad0b57111db95c240c9160a85831c"}, +] +freezegun = [ + {file = "freezegun-0.3.15-py2.py3-none-any.whl", hash = "sha256:82c757a05b7c7ca3e176bfebd7d6779fd9139c7cb4ef969c38a28d74deef89b2"}, + {file = "freezegun-0.3.15.tar.gz", hash = "sha256:e2062f2c7f95cc276a834c22f1a17179467176b624cc6f936e8bc3be5535ad1b"}, +] +gunicorn = [ + {file = "gunicorn-20.0.4-py2.py3-none-any.whl", hash = "sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c"}, + {file = "gunicorn-20.0.4.tar.gz", hash = "sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626"}, +] +idna = [ + {file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"}, + {file = "idna-2.9.tar.gz", hash = "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"}, +] +importlib-metadata = [ + {file = "importlib_metadata-1.6.0-py2.py3-none-any.whl", hash = "sha256:2a688cbaa90e0cc587f1df48bdc97a6eadccdcd9c35fb3f976a09e3b5016d90f"}, + {file = "importlib_metadata-1.6.0.tar.gz", hash = "sha256:34513a8a0c4962bc66d35b359558fd8a5e10cd472d37aec5f66858addef32c1e"}, +] +inflection = [ + {file = "inflection-0.4.0-py2.py3-none-any.whl", hash = "sha256:9a15d3598f01220e93f2207c432cfede50daff53137ce660fb8be838ef1ca6cc"}, + {file = "inflection-0.4.0.tar.gz", hash = "sha256:32a5c3341d9583ec319548b9015b7fbdf8c429cbcb575d326c33ae3a0e90d52c"}, +] +isort = [ + {file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"}, + {file = "isort-4.3.21.tar.gz", hash = "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1"}, +] +itypes = [ + {file = "itypes-1.1.0.tar.gz", hash = "sha256:c6e77bb9fd68a4bfeb9d958fea421802282451a25bac4913ec94db82a899c073"}, +] +jinja2 = [ + {file = "Jinja2-2.11.1-py2.py3-none-any.whl", hash = "sha256:b0eaf100007721b5c16c1fc1eecb87409464edc10469ddc9a22a27a99123be49"}, + {file = "Jinja2-2.11.1.tar.gz", hash = "sha256:93187ffbc7808079673ef52771baa950426fd664d3aad1d0fa3e95644360e250"}, +] +kombu = [ + {file = "kombu-4.6.8-py2.py3-none-any.whl", hash = "sha256:598e7e749d6ab54f646b74b2d2df67755dee13894f73ab02a2a9feb8870c7cb2"}, + {file = "kombu-4.6.8.tar.gz", hash = "sha256:2d1cda774126a044d91a7ff5fa6d09edf99f46924ab332a810760fe6740e9b76"}, +] +lxml = [ + {file = "lxml-4.5.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:0701f7965903a1c3f6f09328c1278ac0eee8f56f244e66af79cb224b7ef3801c"}, + {file = "lxml-4.5.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:06d4e0bbb1d62e38ae6118406d7cdb4693a3fa34ee3762238bcb96c9e36a93cd"}, + {file = "lxml-4.5.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:5828c7f3e615f3975d48f40d4fe66e8a7b25f16b5e5705ffe1d22e43fb1f6261"}, + {file = "lxml-4.5.0-cp27-cp27m-win32.whl", hash = "sha256:afdb34b715daf814d1abea0317b6d672476b498472f1e5aacbadc34ebbc26e89"}, + {file = "lxml-4.5.0-cp27-cp27m-win_amd64.whl", hash = "sha256:585c0869f75577ac7a8ff38d08f7aac9033da2c41c11352ebf86a04652758b7a"}, + {file = "lxml-4.5.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:8a0ebda56ebca1a83eb2d1ac266649b80af8dd4b4a3502b2c1e09ac2f88fe128"}, + {file = "lxml-4.5.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:fe976a0f1ef09b3638778024ab9fb8cde3118f203364212c198f71341c0715ca"}, + {file = "lxml-4.5.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:7bc1b221e7867f2e7ff1933165c0cec7153dce93d0cdba6554b42a8beb687bdb"}, + {file = "lxml-4.5.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:d068f55bda3c2c3fcaec24bd083d9e2eede32c583faf084d6e4b9daaea77dde8"}, + {file = "lxml-4.5.0-cp35-cp35m-win32.whl", hash = "sha256:e4aa948eb15018a657702fee0b9db47e908491c64d36b4a90f59a64741516e77"}, + {file = "lxml-4.5.0-cp35-cp35m-win_amd64.whl", hash = "sha256:1f2c4ec372bf1c4a2c7e4bb20845e8bcf8050365189d86806bad1e3ae473d081"}, + {file = "lxml-4.5.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:5d467ce9c5d35b3bcc7172c06320dddb275fea6ac2037f72f0a4d7472035cea9"}, + {file = "lxml-4.5.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:95e67224815ef86924fbc2b71a9dbd1f7262384bca4bc4793645794ac4200717"}, + {file = "lxml-4.5.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ebec08091a22c2be870890913bdadd86fcd8e9f0f22bcb398abd3af914690c15"}, + {file = "lxml-4.5.0-cp36-cp36m-win32.whl", hash = "sha256:deadf4df349d1dcd7b2853a2c8796593cc346600726eff680ed8ed11812382a7"}, + {file = "lxml-4.5.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f2b74784ed7e0bc2d02bd53e48ad6ba523c9b36c194260b7a5045071abbb1012"}, + {file = "lxml-4.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fa071559f14bd1e92077b1b5f6c22cf09756c6de7139370249eb372854ce51e6"}, + {file = "lxml-4.5.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:edc15fcfd77395e24543be48871c251f38132bb834d9fdfdad756adb6ea37679"}, + {file = "lxml-4.5.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fd52e796fee7171c4361d441796b64df1acfceb51f29e545e812f16d023c4bbc"}, + {file = "lxml-4.5.0-cp37-cp37m-win32.whl", hash = "sha256:90ed0e36455a81b25b7034038e40880189169c308a3df360861ad74da7b68c1a"}, + {file = "lxml-4.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:df533af6f88080419c5a604d0d63b2c33b1c0c4409aba7d0cb6de305147ea8c8"}, + {file = "lxml-4.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b4b2c63cc7963aedd08a5f5a454c9f67251b1ac9e22fd9d72836206c42dc2a72"}, + {file = "lxml-4.5.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e5d842c73e4ef6ed8c1bd77806bf84a7cb535f9c0cf9b2c74d02ebda310070e1"}, + {file = "lxml-4.5.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:63dbc21efd7e822c11d5ddbedbbb08cd11a41e0032e382a0fd59b0b08e405a3a"}, + {file = "lxml-4.5.0-cp38-cp38-win32.whl", hash = "sha256:4235bc124fdcf611d02047d7034164897ade13046bda967768836629bc62784f"}, + {file = "lxml-4.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:d5b3c4b7edd2e770375a01139be11307f04341ec709cf724e0f26ebb1eef12c3"}, + {file = "lxml-4.5.0.tar.gz", hash = "sha256:8620ce80f50d023d414183bf90cc2576c2837b88e00bea3f33ad2630133bbb60"}, +] +markupsafe = [ + {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-win32.whl", hash = "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-win_amd64.whl", hash = "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e"}, + {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f"}, + {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-win32.whl", hash = "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-win_amd64.whl", hash = "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, + {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, +] +packaging = [ + {file = "packaging-20.3-py2.py3-none-any.whl", hash = "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752"}, + {file = "packaging-20.3.tar.gz", hash = "sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3"}, +] +psycopg2-binary = [ + {file = "psycopg2-binary-2.8.5.tar.gz", hash = "sha256:ccdc6a87f32b491129ada4b87a43b1895cf2c20fdb7f98ad979647506ffc41b6"}, + {file = "psycopg2_binary-2.8.5-cp27-cp27m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:96d3038f5bd061401996614f65d27a4ecb62d843eb4f48e212e6d129171a721f"}, + {file = "psycopg2_binary-2.8.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:08507efbe532029adee21b8d4c999170a83760d38249936038bd0602327029b5"}, + {file = "psycopg2_binary-2.8.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:b9a8b391c2b0321e0cd7ec6b4cfcc3dd6349347bd1207d48bcb752aa6c553a66"}, + {file = "psycopg2_binary-2.8.5-cp27-cp27m-win32.whl", hash = "sha256:3286541b9d85a340ee4ed42732d15fc1bb441dc500c97243a768154ab8505bb5"}, + {file = "psycopg2_binary-2.8.5-cp27-cp27m-win_amd64.whl", hash = "sha256:008da3ab51adc70a5f1cfbbe5db3a22607ab030eb44bcecf517ad11a0c2b3cac"}, + {file = "psycopg2_binary-2.8.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:ba13346ff6d3eb2dca0b6fa0d8a9d999eff3dcd9b55f3a890f12b0b6362b2b38"}, + {file = "psycopg2_binary-2.8.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:c8830b7d5f16fd79d39b21e3d94f247219036b29b30c8270314c46bf8b732389"}, + {file = "psycopg2_binary-2.8.5-cp34-cp34m-win32.whl", hash = "sha256:51f7823f1b087d2020d8e8c9e6687473d3d239ba9afc162d9b2ab6e80b53f9f9"}, + {file = "psycopg2_binary-2.8.5-cp34-cp34m-win_amd64.whl", hash = "sha256:107d9be3b614e52a192719c6bf32e8813030020ea1d1215daa86ded9a24d8b04"}, + {file = "psycopg2_binary-2.8.5-cp35-cp35m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:930315ac53dc65cbf52ab6b6d27422611f5fb461d763c531db229c7e1af6c0b3"}, + {file = "psycopg2_binary-2.8.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:6bb2dd006a46a4a4ce95201f836194eb6a1e863f69ee5bab506673e0ca767057"}, + {file = "psycopg2_binary-2.8.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:3939cf75fc89c5e9ed836e228c4a63604dff95ad19aed2bbf71d5d04c15ed5ce"}, + {file = "psycopg2_binary-2.8.5-cp35-cp35m-win32.whl", hash = "sha256:a20299ee0ea2f9cca494396ac472d6e636745652a64a418b39522c120fd0a0a4"}, + {file = "psycopg2_binary-2.8.5-cp35-cp35m-win_amd64.whl", hash = "sha256:cc30cb900f42c8a246e2cb76539d9726f407330bc244ca7729c41a44e8d807fb"}, + {file = "psycopg2_binary-2.8.5-cp36-cp36m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:40abc319f7f26c042a11658bf3dd3b0b3bceccf883ec1c565d5c909a90204434"}, + {file = "psycopg2_binary-2.8.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:702f09d8f77dc4794651f650828791af82f7c2efd8c91ae79e3d9fe4bb7d4c98"}, + {file = "psycopg2_binary-2.8.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d1a8b01f6a964fec702d6b6dac1f91f2b9f9fe41b310cbb16c7ef1fac82df06d"}, + {file = "psycopg2_binary-2.8.5-cp36-cp36m-win32.whl", hash = "sha256:17a0ea0b0eabf07035e5e0d520dabc7950aeb15a17c6d36128ba99b2721b25b1"}, + {file = "psycopg2_binary-2.8.5-cp36-cp36m-win_amd64.whl", hash = "sha256:e004db88e5a75e5fdab1620fb9f90c9598c2a195a594225ac4ed2a6f1c23e162"}, + {file = "psycopg2_binary-2.8.5-cp37-cp37m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:a34826d6465c2e2bbe9d0605f944f19d2480589f89863ed5f091943be27c9de4"}, + {file = "psycopg2_binary-2.8.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:cac918cd7c4c498a60f5d2a61d4f0a6091c2c9490d81bc805c963444032d0dab"}, + {file = "psycopg2_binary-2.8.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:7b832d76cc65c092abd9505cc670c4e3421fd136fb6ea5b94efbe4c146572505"}, + {file = "psycopg2_binary-2.8.5-cp37-cp37m-win32.whl", hash = "sha256:bb0608694a91db1e230b4a314e8ed00ad07ed0c518f9a69b83af2717e31291a3"}, + {file = "psycopg2_binary-2.8.5-cp37-cp37m-win_amd64.whl", hash = "sha256:eb2f43ae3037f1ef5e19339c41cf56947021ac892f668765cd65f8ab9814192e"}, + {file = "psycopg2_binary-2.8.5-cp38-cp38-macosx_10_9_x86_64.macosx_10_9_intel.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:07cf82c870ec2d2ce94d18e70c13323c89f2f2a2628cbf1feee700630be2519a"}, + {file = "psycopg2_binary-2.8.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:a69970ee896e21db4c57e398646af9edc71c003bc52a3cc77fb150240fefd266"}, + {file = "psycopg2_binary-2.8.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7036ccf715925251fac969f4da9ad37e4b7e211b1e920860148a10c0de963522"}, + {file = "psycopg2_binary-2.8.5-cp38-cp38-win32.whl", hash = "sha256:8f74e631b67482d504d7e9cf364071fc5d54c28e79a093ff402d5f8f81e23bfa"}, + {file = "psycopg2_binary-2.8.5-cp38-cp38-win_amd64.whl", hash = "sha256:fa466306fcf6b39b8a61d003123d442b23707d635a5cb05ac4e1b62cc79105cd"}, +] +pyflakes = [ + {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, + {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, +] +pyparsing = [ + {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, + {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, +] +python-crontab = [ + {file = "python-crontab-2.4.1.tar.gz", hash = "sha256:2366c7aa373118315de7c082401907bacd28e8b1e4e0a6d702334d17b89e71aa"}, +] +python-dateutil = [ + {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, + {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, +] +python-dotenv = [ + {file = "python-dotenv-0.12.0.tar.gz", hash = "sha256:92b3123fb2d58a284f76cc92bfe4ee6c502c32ded73e8b051c4f6afc8b6751ed"}, + {file = "python_dotenv-0.12.0-py2.py3-none-any.whl", hash = "sha256:81822227f771e0cab235a2939f0f265954ac4763cafd806d845801c863bf372f"}, +] +python-memcached = [ + {file = "python-memcached-1.59.tar.gz", hash = "sha256:a2e28637be13ee0bf1a8b6843e7490f9456fd3f2a4cb60471733c7b5d5557e4f"}, + {file = "python_memcached-1.59-py2.py3-none-any.whl", hash = "sha256:4dac64916871bd3550263323fc2ce18e1e439080a2d5670c594cf3118d99b594"}, +] +pytz = [ + {file = "pytz-2019.3-py2.py3-none-any.whl", hash = "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d"}, + {file = "pytz-2019.3.tar.gz", hash = "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"}, +] +requests = [ + {file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"}, + {file = "requests-2.23.0.tar.gz", hash = "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"}, +] +"ruamel.yaml" = [ + {file = "ruamel.yaml-0.16.10-py2.py3-none-any.whl", hash = "sha256:0962fd7999e064c4865f96fb1e23079075f4a2a14849bcdc5cdba53a24f9759b"}, + {file = "ruamel.yaml-0.16.10.tar.gz", hash = "sha256:099c644a778bf72ffa00524f78dd0b6476bca94a1da344130f4bf3381ce5b954"}, +] +"ruamel.yaml.clib" = [ + {file = "ruamel.yaml.clib-0.2.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:9c6d040d0396c28d3eaaa6cb20152cb3b2f15adf35a0304f4f40a3cf9f1d2448"}, + {file = "ruamel.yaml.clib-0.2.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:4d55386129291b96483edcb93b381470f7cd69f97585829b048a3d758d31210a"}, + {file = "ruamel.yaml.clib-0.2.0-cp27-cp27m-win32.whl", hash = "sha256:8073c8b92b06b572e4057b583c3d01674ceaf32167801fe545a087d7a1e8bf52"}, + {file = "ruamel.yaml.clib-0.2.0-cp27-cp27m-win_amd64.whl", hash = "sha256:615b0396a7fad02d1f9a0dcf9f01202bf9caefee6265198f252c865f4227fcc6"}, + {file = "ruamel.yaml.clib-0.2.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:a0ff786d2a7dbe55f9544b3f6ebbcc495d7e730df92a08434604f6f470b899c5"}, + {file = "ruamel.yaml.clib-0.2.0-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:ea4362548ee0cbc266949d8a441238d9ad3600ca9910c3fe4e82ee3a50706973"}, + {file = "ruamel.yaml.clib-0.2.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:77556a7aa190be9a2bd83b7ee075d3df5f3c5016d395613671487e79b082d784"}, + {file = "ruamel.yaml.clib-0.2.0-cp35-cp35m-win32.whl", hash = "sha256:392b7c371312abf27fb549ec2d5e0092f7ef6e6c9f767bfb13e83cb903aca0fd"}, + {file = "ruamel.yaml.clib-0.2.0-cp35-cp35m-win_amd64.whl", hash = "sha256:ed5b3698a2bb241b7f5cbbe277eaa7fe48b07a58784fba4f75224fd066d253ad"}, + {file = "ruamel.yaml.clib-0.2.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:7aee724e1ff424757b5bd8f6c5bbdb033a570b2b4683b17ace4dbe61a99a657b"}, + {file = "ruamel.yaml.clib-0.2.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d0d3ac228c9bbab08134b4004d748cf9f8743504875b3603b3afbb97e3472947"}, + {file = "ruamel.yaml.clib-0.2.0-cp36-cp36m-win32.whl", hash = "sha256:f9dcc1ae73f36e8059589b601e8e4776b9976effd76c21ad6a855a74318efd6e"}, + {file = "ruamel.yaml.clib-0.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:1e77424825caba5553bbade750cec2277ef130647d685c2b38f68bc03453bac6"}, + {file = "ruamel.yaml.clib-0.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d10e9dd744cf85c219bf747c75194b624cc7a94f0c80ead624b06bfa9f61d3bc"}, + {file = "ruamel.yaml.clib-0.2.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:550168c02d8de52ee58c3d8a8193d5a8a9491a5e7b2462d27ac5bf63717574c9"}, + {file = "ruamel.yaml.clib-0.2.0-cp37-cp37m-win32.whl", hash = "sha256:57933a6986a3036257ad7bf283529e7c19c2810ff24c86f4a0cfeb49d2099919"}, + {file = "ruamel.yaml.clib-0.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:b1b7fcee6aedcdc7e62c3a73f238b3d080c7ba6650cd808bce8d7761ec484070"}, + {file = "ruamel.yaml.clib-0.2.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:be018933c2f4ee7de55e7bd7d0d801b3dfb09d21dad0cce8a97995fd3e44be30"}, + {file = "ruamel.yaml.clib-0.2.0.tar.gz", hash = "sha256:b66832ea8077d9b3f6e311c4a53d06273db5dc2db6e8a908550f3c14d67e718c"}, +] +six = [ + {file = "six-1.14.0-py2.py3-none-any.whl", hash = "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"}, + {file = "six-1.14.0.tar.gz", hash = "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a"}, +] +soupsieve = [ + {file = "soupsieve-1.9.5-py2.py3-none-any.whl", hash = "sha256:bdb0d917b03a1369ce964056fc195cfdff8819c40de04695a80bc813c3cfa1f5"}, + {file = "soupsieve-1.9.5.tar.gz", hash = "sha256:e2c1c5dee4a1c36bcb790e0fabd5492d874b8ebd4617622c4f6a731701060dda"}, +] +sqlparse = [ + {file = "sqlparse-0.3.1-py2.py3-none-any.whl", hash = "sha256:022fb9c87b524d1f7862b3037e541f68597a730a8843245c349fc93e1643dc4e"}, + {file = "sqlparse-0.3.1.tar.gz", hash = "sha256:e162203737712307dfe78860cc56c8da8a852ab2ee33750e33aeadf38d12c548"}, +] +tblib = [ + {file = "tblib-1.6.0-py2.py3-none-any.whl", hash = "sha256:e222f44485d45ed13fada73b57775e2ff9bd8af62160120bbb6679f5ad80315b"}, + {file = "tblib-1.6.0.tar.gz", hash = "sha256:229bee3754cb5d98b4837dd5c4405e80cfab57cb9f93220410ad367f8b352344"}, +] +text-unidecode = [ + {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"}, + {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, +] +toml = [ + {file = "toml-0.10.0-py2.7.egg", hash = "sha256:f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"}, + {file = "toml-0.10.0-py2.py3-none-any.whl", hash = "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"}, + {file = "toml-0.10.0.tar.gz", hash = "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c"}, +] +uritemplate = [ + {file = "uritemplate-3.0.1-py2.py3-none-any.whl", hash = "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f"}, + {file = "uritemplate-3.0.1.tar.gz", hash = "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae"}, +] +urllib3 = [ + {file = "urllib3-1.25.8-py2.py3-none-any.whl", hash = "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc"}, + {file = "urllib3-1.25.8.tar.gz", hash = "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"}, +] +vine = [ + {file = "vine-1.3.0-py2.py3-none-any.whl", hash = "sha256:ea4947cc56d1fd6f2095c8d543ee25dad966f78692528e68b4fada11ba3f98af"}, + {file = "vine-1.3.0.tar.gz", hash = "sha256:133ee6d7a9016f177ddeaf191c1f58421a1dcc6ee9a42c58b34bed40e1d2cd87"}, +] +webencodings = [ + {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, + {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, +] +zipp = [ + {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"}, + {file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3755c59 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[tool.poetry] +name = "newsreader" +version = "0.2" +description = "Webapplication for reading RSS feeds" +authors = ["Sonny "] +license = "GPL-3.0" + +[tool.poetry.dependencies] +python = "^3.7" +bleach = "^3.1.4" +Django = "^3.0.5" +celery = "^4.4.2" +beautifulsoup4 = "^4.9.0" +django-axes = "^5.3.1" +django-celery-beat = "^2.0.0" +djangorestframework = "^3.11.0" +drf-yasg = "^1.17.1" +django-registration-redux = "^2.7" +lxml = "^4.5.0" +feedparser = "^5.2.1" +python-memcached = "^1.59" +requests = "^2.23.0" +psycopg2-binary = "^2.8.5" +gunicorn = "^20.0.4" +python-dotenv = "^0.12.0" + +[tool.poetry.dev-dependencies] +factory-boy = "^2.12.0" +freezegun = "^0.3.15" +django-debug-toolbar = "^2.2" +django-extensions = "^2.2.9" +black = "19.3b0" +isort = "4.3.21" +autoflake = "1.3.1" +tblib = "1.6.0" + +[build-system] +requires = ["poetry>=0.12"] +build-backend = "poetry.masonry.api" diff --git a/requirements/base.txt b/requirements/base.txt deleted file mode 100644 index a266589..0000000 --- a/requirements/base.txt +++ /dev/null @@ -1,21 +0,0 @@ -bleach==3.1.0 -beautifulsoup4==4.7.1 -celery==4.3.0 -certifi==2019.3.9 -chardet==3.0.4 -django-axes==5.2.2 -Django==2.2 -django-celery-beat==1.5.0 -djangorestframework==3.9.4 -drf-yasg==1.17.1 -django-registration-redux==2.6 -lxml==4.4.2 -feedparser==5.2.1 -idna==2.8 -python-memcached==1.59 -pytz==2018.9 -requests==2.21.0 -sqlparse==0.3.0 -urllib3==1.24.1 -psycopg2-binary==2.8.1 -Pillow==6.0.0 diff --git a/requirements/dev.txt b/requirements/dev.txt deleted file mode 100644 index d32f624..0000000 --- a/requirements/dev.txt +++ /dev/null @@ -1,10 +0,0 @@ --r base.txt - -factory-boy==2.12.0 -freezegun==0.3.12 -django-debug-toolbar==2.0 -django-extensions==2.1.9 - -black==19.3b0 -isort==4.3.21 -autoflake==1.3.1 diff --git a/requirements/gitlab.txt b/requirements/gitlab.txt deleted file mode 100644 index 429c53d..0000000 --- a/requirements/gitlab.txt +++ /dev/null @@ -1 +0,0 @@ --r dev.txt diff --git a/requirements/production.txt b/requirements/production.txt deleted file mode 100644 index a12fd45..0000000 --- a/requirements/production.txt +++ /dev/null @@ -1,4 +0,0 @@ --r base.txt - -python-dotenv==0.12.0 -gunicorn==20.0.4 diff --git a/requirements/testing.txt b/requirements/testing.txt deleted file mode 100644 index 0a685e3..0000000 --- a/requirements/testing.txt +++ /dev/null @@ -1,4 +0,0 @@ --r base.txt - -factory-boy==2.12.0 -freezegun==0.3.12 diff --git a/src/entrypoint.sh b/src/entrypoint.sh index 3fbf941..451b7d3 100755 --- a/src/entrypoint.sh +++ b/src/entrypoint.sh @@ -1,5 +1,5 @@ #!/bin/bash # This file should only be used in conjuction with docker-compose -python /app/src/manage.py migrate -python /app/src/manage.py runserver 0.0.0.0:8000 +poetry run /app/src/manage.py migrate +poetry run /app/src/manage.py runserver 0.0.0.0:8000 diff --git a/src/newsreader/accounts/migrations/0001_initial.py b/src/newsreader/accounts/migrations/0001_initial.py index 1f58ca8..17b5729 100644 --- a/src/newsreader/accounts/migrations/0001_initial.py +++ b/src/newsreader/accounts/migrations/0001_initial.py @@ -116,7 +116,7 @@ class Migration(migrations.Migration): blank=True, editable=False, null=True, - on_delete="collection task", + on_delete=models.SET_NULL, to="django_celery_beat.PeriodicTask", ), ), @@ -125,7 +125,7 @@ class Migration(migrations.Migration): models.ForeignKey( blank=True, null=True, - on_delete="collection schedule", + on_delete=models.SET_NULL, to="django_celery_beat.IntervalSchedule", ), ), diff --git a/src/newsreader/accounts/migrations/0004_auto_20190714_1501.py b/src/newsreader/accounts/migrations/0004_auto_20190714_1501.py index ba2fc84..69a78e3 100644 --- a/src/newsreader/accounts/migrations/0004_auto_20190714_1501.py +++ b/src/newsreader/accounts/migrations/0004_auto_20190714_1501.py @@ -15,7 +15,7 @@ class Migration(migrations.Migration): blank=True, editable=False, null=True, - on_delete="collection task", + on_delete=models.SET_NULL, to="django_celery_beat.PeriodicTask", ), ) diff --git a/src/newsreader/news/collection/migrations/0002_auto_20190714_1036.py b/src/newsreader/news/collection/migrations/0002_auto_20190714_1036.py index 09c01cf..6854c0b 100644 --- a/src/newsreader/news/collection/migrations/0002_auto_20190714_1036.py +++ b/src/newsreader/news/collection/migrations/0002_auto_20190714_1036.py @@ -32,6 +32,8 @@ class Migration(migrations.Migration): migrations.AddField( model_name="collectionrule", name="user", - field=models.ForeignKey(on_delete="Owner", to=settings.AUTH_USER_MODEL), + field=models.ForeignKey( + on_delete=models.CASCADE, to=settings.AUTH_USER_MODEL + ), ), ] diff --git a/src/newsreader/news/collection/migrations/0003_auto_20190714_1417.py b/src/newsreader/news/collection/migrations/0003_auto_20190714_1417.py index 9f86c32..99f1018 100644 --- a/src/newsreader/news/collection/migrations/0003_auto_20190714_1417.py +++ b/src/newsreader/news/collection/migrations/0003_auto_20190714_1417.py @@ -13,7 +13,9 @@ class Migration(migrations.Migration): model_name="collectionrule", name="user", field=models.ForeignKey( - on_delete="Owner", related_name="rules", to=settings.AUTH_USER_MODEL + on_delete=models.CASCADE, + related_name="rules", + to=settings.AUTH_USER_MODEL, ), ) ] diff --git a/src/newsreader/news/collection/migrations/0006_auto_20200412_1955.py b/src/newsreader/news/collection/migrations/0006_auto_20200412_1955.py new file mode 100644 index 0000000..441d7f1 --- /dev/null +++ b/src/newsreader/news/collection/migrations/0006_auto_20200412_1955.py @@ -0,0 +1,27 @@ +# Generated by Django 3.0.5 on 2020-04-12 19:55 + +import django.db.models.deletion + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("collection", "0005_auto_20200303_1932"), + ] + + operations = [ + migrations.AlterField( + model_name="collectionrule", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="rules", + to=settings.AUTH_USER_MODEL, + verbose_name="Owner", + ), + ) + ] diff --git a/src/newsreader/news/collection/models.py b/src/newsreader/news/collection/models.py index 8552ebf..d1d62ce 100644 --- a/src/newsreader/news/collection/models.py +++ b/src/newsreader/news/collection/models.py @@ -35,7 +35,12 @@ class CollectionRule(TimeStampedModel): succeeded = models.BooleanField(default=False) error = models.CharField(max_length=1024, blank=True, null=True) - user = models.ForeignKey("accounts.User", _("Owner"), related_name="rules") + user = models.ForeignKey( + "accounts.User", + verbose_name=_("Owner"), + related_name="rules", + on_delete=models.CASCADE, + ) def __str__(self): return self.name diff --git a/src/newsreader/news/core/migrations/0001_initial.py b/src/newsreader/news/core/migrations/0001_initial.py index be138d9..eb74fc7 100644 --- a/src/newsreader/news/core/migrations/0001_initial.py +++ b/src/newsreader/news/core/migrations/0001_initial.py @@ -70,7 +70,9 @@ class Migration(migrations.Migration): ("name", models.CharField(max_length=50, unique=True)), ( "user", - models.ForeignKey(on_delete="Owner", to=settings.AUTH_USER_MODEL), + models.ForeignKey( + on_delete=models.CASCADE, to=settings.AUTH_USER_MODEL + ), ), ], options={"verbose_name": "Category", "verbose_name_plural": "Categories"}, diff --git a/src/newsreader/news/core/migrations/0002_auto_20190714_1425.py b/src/newsreader/news/core/migrations/0002_auto_20190714_1425.py index bbf4c1a..4d9ad4f 100644 --- a/src/newsreader/news/core/migrations/0002_auto_20190714_1425.py +++ b/src/newsreader/news/core/migrations/0002_auto_20190714_1425.py @@ -15,7 +15,7 @@ class Migration(migrations.Migration): model_name="category", name="user", field=models.ForeignKey( - on_delete="Owner", + on_delete=models.CASCADE, related_name="categories", to=settings.AUTH_USER_MODEL, ), diff --git a/src/newsreader/news/core/migrations/0005_auto_20200412_1955.py b/src/newsreader/news/core/migrations/0005_auto_20200412_1955.py new file mode 100644 index 0000000..0010448 --- /dev/null +++ b/src/newsreader/news/core/migrations/0005_auto_20200412_1955.py @@ -0,0 +1,27 @@ +# Generated by Django 3.0.5 on 2020-04-12 19:55 + +import django.db.models.deletion + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("core", "0004_auto_20191116_1315"), + ] + + operations = [ + migrations.AlterField( + model_name="category", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="categories", + to=settings.AUTH_USER_MODEL, + verbose_name="Owner", + ), + ) + ] diff --git a/src/newsreader/news/core/models.py b/src/newsreader/news/core/models.py index d77b23b..64028d2 100644 --- a/src/newsreader/news/core/models.py +++ b/src/newsreader/news/core/models.py @@ -27,7 +27,12 @@ class Post(TimeStampedModel): class Category(TimeStampedModel): name = models.CharField(max_length=50) - user = models.ForeignKey("accounts.User", _("Owner"), related_name="categories") + user = models.ForeignKey( + "accounts.User", + verbose_name=_("Owner"), + related_name="categories", + on_delete=models.CASCADE, + ) @property def rule_ids(self): From 7d86cea6ec5e39ff5fab9d407334299dc9ade002 Mon Sep 17 00:00:00 2001 From: Sonny Date: Wed, 15 Apr 2020 21:59:34 +0200 Subject: [PATCH 075/422] Update project / gitlab ci settings --- .coveragerc | 16 +++++ .gitignore | 1 + .gitlab-ci.yml | 116 ++++++------------------------ gitlab-ci/build.yml | 7 ++ gitlab-ci/deploy.yml | 16 +++++ gitlab-ci/lint.yml | 22 ++++++ gitlab-ci/test.yml | 23 ++++++ poetry.lock | 46 +++++++++++- pyproject.toml | 1 + src/newsreader/conf/base.py | 4 +- src/newsreader/conf/dev.py | 4 ++ src/newsreader/conf/production.py | 5 ++ 12 files changed, 162 insertions(+), 99 deletions(-) create mode 100644 .coveragerc create mode 100644 gitlab-ci/build.yml create mode 100644 gitlab-ci/deploy.yml create mode 100644 gitlab-ci/lint.yml create mode 100644 gitlab-ci/test.yml diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..d1a0d79 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,16 @@ +[run] +source = ./src/newsreader/ +omit = + **/tests/** + **/migrations/** + **/conf/** + **/apps.py + **/admin.py + **/tests.py + **/urls.py + **/wsgi.py + **/celery.py + **/__init__.py + +[html] +directory = coverage diff --git a/.gitignore b/.gitignore index f29dcf2..754490c 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,7 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ +coverage/ .tox/ .nox/ .coverage diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0a89ab2..fd895d6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -4,101 +4,25 @@ stages: - lint - deploy -javascript build: - image: node:12 - stage: build - cache: - key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG" - paths: - - node_modules/ - before_script: - - npm install - script: - - npm run build +variables: + PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" + DJANGO_SETTINGS_MODULE: "newsreader.conf.gitlab" + POSTGRES_HOST: "$POSTGRES_HOST" + POSTGRES_DB: "$POSTGRES_NAME" + POSTGRES_NAME: "$POSTGRES_NAME" + POSTGRES_USER: "$POSTGRES_USER" + POSTGRES_PASSWORD: "$POSTGRES_PASSWORD" -python tests: - services: - - postgres:11 - - memcached:1.5.22 - image: python:3.7.4-slim-stretch - stage: test - variables: - PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" - DJANGO_SETTINGS_MODULE: "newsreader.conf.gitlab" - POSTGRES_HOST: "$POSTGRES_HOST" - POSTGRES_DB: "$POSTGRES_NAME" - POSTGRES_NAME: "$POSTGRES_NAME" - POSTGRES_USER: "$POSTGRES_USER" - POSTGRES_PASSWORD: "$POSTGRES_PASSWORD" - cache: - key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG" - paths: - - .cache/pip - - .venv/ - before_script: - - pip install poetry - - poetry config virtualenvs.in-project true - - poetry install --no-interaction - script: - - poetry run src/manage.py test newsreader +cache: + key: "$CI_COMMIT_REF_SLUG" + paths: + - .venv/ + - .cache/pip + - .cache/poetry + - node_modules/ -javascript tests: - image: node:12 - stage: test - cache: - key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG" - paths: - - node_modules/ - before_script: - - npm install - script: - - npm test - -javascript linting: - image: node:12 - stage: lint - cache: - key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG" - paths: - - node_modules/ - before_script: - - npm install - script: - - npm run lint - -python linting: - image: python:3.7.4-slim-stretch - stage: lint - variables: - PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" - DJANGO_SETTINGS_MODULE: "newsreader.conf.gitlab" - cache: - key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG" - paths: - - .cache/pip - - .venv/ - before_script: - - pip install poetry - - poetry config virtualenvs.in-project true - - poetry install --no-interaction - script: - - poetry run isort src/ --check-only --recursive - - poetry run black src/ --line-length 88 --check - - poetry run autoflake src/ --check --recursive --remove-all-unused-imports --ignore-init-module-imports - -deploy: - stage: deploy - image: debian:buster - environment: - name: production - url: rss.fudiggity.nl - before_script: - - apt-get update && apt-get install -y ansible git - - git clone https://gitlab-ci-token:${CI_JOB_TOKEN}@git.fudiggity.nl/sonny/ansible-playbooks.git deployment - - mkdir /root/.ssh - - echo "192.168.178.63 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILbtcdgJBhVCKsO88cV19EYefDTopdYejEQCp1pYr1Ga" > /root/.ssh/known_hosts - - echo "$DEPLOY_KEY" > deployment/deploy_key && chmod 0600 deployment/deploy_key - script: - - ansible-playbook deployment/playbook.yml --inventory deployment/apps.yml --limit newsreader --user ansible --private-key deployment/deploy_key - only: - - development +include: + - local: '/gitlab-ci/build.yml' + - local: '/gitlab-ci/test.yml' + - local: '/gitlab-ci/lint.yml' + - local: '/gitlab-ci/deploy.yml' diff --git a/gitlab-ci/build.yml b/gitlab-ci/build.yml new file mode 100644 index 0000000..c8df615 --- /dev/null +++ b/gitlab-ci/build.yml @@ -0,0 +1,7 @@ +static: + stage: build + image: node:12 + before_script: + - npm install + script: + - npm run build diff --git a/gitlab-ci/deploy.yml b/gitlab-ci/deploy.yml new file mode 100644 index 0000000..fedc5eb --- /dev/null +++ b/gitlab-ci/deploy.yml @@ -0,0 +1,16 @@ +deploy: + stage: deploy + image: debian:buster + environment: + name: production + url: rss.fudiggity.nl + before_script: + - apt-get update && apt-get install -y ansible git + - git clone https://gitlab-ci-token:${CI_JOB_TOKEN}@git.fudiggity.nl/sonny/ansible-playbooks.git deployment + - mkdir /root/.ssh + - echo "192.168.178.63 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILbtcdgJBhVCKsO88cV19EYefDTopdYejEQCp1pYr1Ga" > /root/.ssh/known_hosts + - echo "$DEPLOY_KEY" > deployment/deploy_key && chmod 0600 deployment/deploy_key + script: + - ansible-playbook deployment/playbook.yml --inventory deployment/apps.yml --limit newsreader --user ansible --private-key deployment/deploy_key + only: + - master diff --git a/gitlab-ci/lint.yml b/gitlab-ci/lint.yml new file mode 100644 index 0000000..3f1e259 --- /dev/null +++ b/gitlab-ci/lint.yml @@ -0,0 +1,22 @@ +python-linting: + stage: lint + allow_failure: true + image: python:3.7.4-slim-stretch + before_script: + - pip install poetry + - poetry config cache-dir ~/.cache/poetry + - poetry config virtualenvs.in-project true + - poetry install --no-interaction + script: + - poetry run isort src/ --check-only --recursive + - poetry run black src/ --line-length 88 --check + - poetry run autoflake src/ --check --recursive --remove-all-unused-imports --ignore-init-module-imports + +javascript-linting: + stage: lint + allow_failure: true + image: node:12 + before_script: + - npm install + script: + - npm run lint diff --git a/gitlab-ci/test.yml b/gitlab-ci/test.yml new file mode 100644 index 0000000..3e8eccb --- /dev/null +++ b/gitlab-ci/test.yml @@ -0,0 +1,23 @@ +python-tests: + stage: test + coverage: '/TOTAL\s+\d+\s+\d+\s+(\d+%)/' + services: + - postgres:11 + - memcached:1.5.22 + image: python:3.7.4-slim-stretch + before_script: + - pip install poetry + - poetry config cache-dir .cache/poetry + - poetry config virtualenvs.in-project true + - poetry install --no-interaction + script: + - poetry run coverage run src/manage.py test newsreader + - poetry run coverage report + +javascript-tests: + stage: test + image: node:12 + before_script: + - npm install + script: + - npm test diff --git a/poetry.lock b/poetry.lock index cfb606f..0681268 100644 --- a/poetry.lock +++ b/poetry.lock @@ -202,6 +202,17 @@ version = "0.0.4" [package.dependencies] jinja2 = "*" +[[package]] +category = "dev" +description = "Code coverage measurement for Python" +name = "coverage" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" +version = "5.1" + +[package.extras] +toml = ["toml"] + [[package]] category = "main" description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." @@ -757,7 +768,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] [metadata] -content-hash = "28046079901294125f012386748aa5433bc92b8237514e3f7e1bbceb0258ae44" +content-hash = "38cc29547dab994d438a7a4082fca9f557acfff59626df37ec9ee9f15ff094a0" python-versions = "^3.7" [metadata.files] @@ -821,6 +832,39 @@ coreschema = [ {file = "coreschema-0.0.4-py2-none-any.whl", hash = "sha256:5e6ef7bf38c1525d5e55a895934ab4273548629f16aed5c0a6caa74ebf45551f"}, {file = "coreschema-0.0.4.tar.gz", hash = "sha256:9503506007d482ab0867ba14724b93c18a33b22b6d19fb419ef2d239dd4a1607"}, ] +coverage = [ + {file = "coverage-5.1-cp27-cp27m-macosx_10_12_x86_64.whl", hash = "sha256:0cb4be7e784dcdc050fc58ef05b71aa8e89b7e6636b99967fadbdba694cf2b65"}, + {file = "coverage-5.1-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:c317eaf5ff46a34305b202e73404f55f7389ef834b8dbf4da09b9b9b37f76dd2"}, + {file = "coverage-5.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b83835506dfc185a319031cf853fa4bb1b3974b1f913f5bb1a0f3d98bdcded04"}, + {file = "coverage-5.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:5f2294dbf7875b991c381e3d5af2bcc3494d836affa52b809c91697449d0eda6"}, + {file = "coverage-5.1-cp27-cp27m-win32.whl", hash = "sha256:de807ae933cfb7f0c7d9d981a053772452217df2bf38e7e6267c9cbf9545a796"}, + {file = "coverage-5.1-cp27-cp27m-win_amd64.whl", hash = "sha256:bf9cb9a9fd8891e7efd2d44deb24b86d647394b9705b744ff6f8261e6f29a730"}, + {file = "coverage-5.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:acf3763ed01af8410fc36afea23707d4ea58ba7e86a8ee915dfb9ceff9ef69d0"}, + {file = "coverage-5.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:dec5202bfe6f672d4511086e125db035a52b00f1648d6407cc8e526912c0353a"}, + {file = "coverage-5.1-cp35-cp35m-macosx_10_12_x86_64.whl", hash = "sha256:7a5bdad4edec57b5fb8dae7d3ee58622d626fd3a0be0dfceda162a7035885ecf"}, + {file = "coverage-5.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:1601e480b9b99697a570cea7ef749e88123c04b92d84cedaa01e117436b4a0a9"}, + {file = "coverage-5.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:dbe8c6ae7534b5b024296464f387d57c13caa942f6d8e6e0346f27e509f0f768"}, + {file = "coverage-5.1-cp35-cp35m-win32.whl", hash = "sha256:a027ef0492ede1e03a8054e3c37b8def89a1e3c471482e9f046906ba4f2aafd2"}, + {file = "coverage-5.1-cp35-cp35m-win_amd64.whl", hash = "sha256:0e61d9803d5851849c24f78227939c701ced6704f337cad0a91e0972c51c1ee7"}, + {file = "coverage-5.1-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:2d27a3f742c98e5c6b461ee6ef7287400a1956c11421eb574d843d9ec1f772f0"}, + {file = "coverage-5.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:66460ab1599d3cf894bb6baee8c684788819b71a5dc1e8fa2ecc152e5d752019"}, + {file = "coverage-5.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:5c542d1e62eece33c306d66fe0a5c4f7f7b3c08fecc46ead86d7916684b36d6c"}, + {file = "coverage-5.1-cp36-cp36m-win32.whl", hash = "sha256:2742c7515b9eb368718cd091bad1a1b44135cc72468c731302b3d641895b83d1"}, + {file = "coverage-5.1-cp36-cp36m-win_amd64.whl", hash = "sha256:dead2ddede4c7ba6cb3a721870f5141c97dc7d85a079edb4bd8d88c3ad5b20c7"}, + {file = "coverage-5.1-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:01333e1bd22c59713ba8a79f088b3955946e293114479bbfc2e37d522be03355"}, + {file = "coverage-5.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:e1ea316102ea1e1770724db01998d1603ed921c54a86a2efcb03428d5417e489"}, + {file = "coverage-5.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:adeb4c5b608574a3d647011af36f7586811a2c1197c861aedb548dd2453b41cd"}, + {file = "coverage-5.1-cp37-cp37m-win32.whl", hash = "sha256:782caea581a6e9ff75eccda79287daefd1d2631cc09d642b6ee2d6da21fc0a4e"}, + {file = "coverage-5.1-cp37-cp37m-win_amd64.whl", hash = "sha256:00f1d23f4336efc3b311ed0d807feb45098fc86dee1ca13b3d6768cdab187c8a"}, + {file = "coverage-5.1-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:402e1744733df483b93abbf209283898e9f0d67470707e3c7516d84f48524f55"}, + {file = "coverage-5.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:a3f3654d5734a3ece152636aad89f58afc9213c6520062db3978239db122f03c"}, + {file = "coverage-5.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6402bd2fdedabbdb63a316308142597534ea8e1895f4e7d8bf7476c5e8751fef"}, + {file = "coverage-5.1-cp38-cp38-win32.whl", hash = "sha256:8fa0cbc7ecad630e5b0f4f35b0f6ad419246b02bc750de7ac66db92667996d24"}, + {file = "coverage-5.1-cp38-cp38-win_amd64.whl", hash = "sha256:79a3cfd6346ce6c13145731d39db47b7a7b859c0272f02cdb89a3bdcbae233a0"}, + {file = "coverage-5.1-cp39-cp39-win32.whl", hash = "sha256:a82b92b04a23d3c8a581fc049228bafde988abacba397d57ce95fe95e0338ab4"}, + {file = "coverage-5.1-cp39-cp39-win_amd64.whl", hash = "sha256:bb28a7245de68bf29f6fb199545d072d1036a1917dca17a1e75bbb919e14ee8e"}, + {file = "coverage-5.1.tar.gz", hash = "sha256:f90bfc4ad18450c80b024036eaf91e4a246ae287701aaa88eaebebf150868052"}, +] django = [ {file = "Django-3.0.5-py3-none-any.whl", hash = "sha256:642d8eceab321ca743ae71e0f985ff8fdca59f07aab3a9fb362c617d23e33a76"}, {file = "Django-3.0.5.tar.gz", hash = "sha256:d4666c2edefa38c5ede0ec1655424c56dc47ceb04b6d8d62a7eac09db89545c1"}, diff --git a/pyproject.toml b/pyproject.toml index 3755c59..047c544 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ black = "19.3b0" isort = "4.3.21" autoflake = "1.3.1" tblib = "1.6.0" +coverage = "^5.1" [build-system] requires = ["poetry>=0.12"] diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 211e1d5..743973d 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -112,7 +112,7 @@ AUTH_USER_MODEL = "accounts.User" # https://docs.djangoproject.com/en/2.2/topics/i18n/ LANGUAGE_CODE = "en-us" -TIME_ZONE = "UTC" +TIME_ZONE = "Europe/Amsterdam" USE_I18N = True USE_L10N = True USE_TZ = True @@ -156,5 +156,5 @@ SWAGGER_SETTINGS = { } REGISTRATION_OPEN = True -ACCOUNT_ACTIVATION_DAYS = 7 REGISTRATION_AUTO_LOGIN = True +ACCOUNT_ACTIVATION_DAYS = 7 diff --git a/src/newsreader/conf/dev.py b/src/newsreader/conf/dev.py index 7f4b5da..b81a9fa 100644 --- a/src/newsreader/conf/dev.py +++ b/src/newsreader/conf/dev.py @@ -25,6 +25,10 @@ TEMPLATES = [ } ] +# Third party settings +AXES_FAILURE_LIMIT = 50 +AXES_COOLOFF_TIME = None + try: from .local import * # noqa except ImportError: diff --git a/src/newsreader/conf/production.py b/src/newsreader/conf/production.py index ce39853..f287498 100644 --- a/src/newsreader/conf/production.py +++ b/src/newsreader/conf/production.py @@ -38,3 +38,8 @@ TEMPLATES = [ }, } ] + +# Third party settings +AXES_HANDLER = "axes.handlers.database.DatabaseHandler" + +REGISTRATION_OPEN = False From 6f00da37b969cb4a688f1c16ce89c9407ed1983a Mon Sep 17 00:00:00 2001 From: sonny Date: Sun, 19 Apr 2020 20:59:14 +0200 Subject: [PATCH 076/422] Resolve "Error pages" --- src/newsreader/core/views.py | 18 +++++++++++++++++- src/newsreader/templates/400.html | 18 ++++++++++++++++++ src/newsreader/templates/403.html | 18 ++++++++++++++++++ src/newsreader/templates/404.html | 18 ++++++++++++++++++ src/newsreader/templates/500.html | 18 ++++++++++++++++++ src/newsreader/urls.py | 5 +++++ 6 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 src/newsreader/templates/400.html create mode 100644 src/newsreader/templates/403.html create mode 100644 src/newsreader/templates/404.html create mode 100644 src/newsreader/templates/500.html diff --git a/src/newsreader/core/views.py b/src/newsreader/core/views.py index 60f00ef..dab9fb1 100644 --- a/src/newsreader/core/views.py +++ b/src/newsreader/core/views.py @@ -1 +1,17 @@ -# Create your views here. +from django.shortcuts import render + + +def bad_request(request, exception): + return render(request, "400.html", status=400) + + +def permission_denied(request, exception): + return render(request, "403.html", status=403) + + +def not_found(request, exception): + return render(request, "404.html", status=404) + + +def server_error(request): + return render(request, "500.html", status=500) diff --git a/src/newsreader/templates/400.html b/src/newsreader/templates/400.html new file mode 100644 index 0000000..8286da1 --- /dev/null +++ b/src/newsreader/templates/400.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% load static i18n %} + +{% block content %} +
      +
      +
      +

      {% trans "Bad request" %}

      +
      +
      +

      + Head back to the login page +

      +
      + +
      +{% endblock %} diff --git a/src/newsreader/templates/403.html b/src/newsreader/templates/403.html new file mode 100644 index 0000000..d9a8fa7 --- /dev/null +++ b/src/newsreader/templates/403.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% load static i18n %} + +{% block content %} +
      +
      +
      +

      {% trans "Permission denied" %}

      +
      +
      +

      + Head back to the login page +

      +
      + +
      +{% endblock %} diff --git a/src/newsreader/templates/404.html b/src/newsreader/templates/404.html new file mode 100644 index 0000000..1550ed9 --- /dev/null +++ b/src/newsreader/templates/404.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% load static i18n %} + +{% block content %} +
      +{% endblock %} diff --git a/src/newsreader/templates/500.html b/src/newsreader/templates/500.html new file mode 100644 index 0000000..31dcfd7 --- /dev/null +++ b/src/newsreader/templates/500.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% load static i18n %} + +{% block content %} +
      +
      +{% endblock %} diff --git a/src/newsreader/urls.py b/src/newsreader/urls.py index 3b01563..c609d91 100644 --- a/src/newsreader/urls.py +++ b/src/newsreader/urls.py @@ -30,6 +30,11 @@ urlpatterns = [ path("api/auth/", include("rest_framework.urls"), name="rest_framework"), ] +handler400 = "newsreader.core.views.bad_request" +handler403 = "newsreader.core.views.permission_denied" +handler404 = "newsreader.core.views.not_found" +handler500 = "newsreader.core.views.server_error" + if settings.DEBUG: import debug_toolbar From 6ff26c71a0abf4741937e6b0ee4150ea6a9283bc Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 19 Apr 2020 21:13:38 +0200 Subject: [PATCH 077/422] Update navbar styling --- .../scss/components/messages/_messages.scss | 2 +- src/newsreader/scss/components/navbar/_navbar.scss | 6 +++--- src/newsreader/scss/components/rules/_rules.scss | 4 ++-- src/newsreader/scss/partials/_colors.scss | 13 +++++++------ 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/newsreader/scss/components/messages/_messages.scss b/src/newsreader/scss/components/messages/_messages.scss index 5779820..1d46932 100644 --- a/src/newsreader/scss/components/messages/_messages.scss +++ b/src/newsreader/scss/components/messages/_messages.scss @@ -19,7 +19,7 @@ border-radius: 5px; - background-color: $focus-blue; + background-color: $blue; &--error { background-color: $error-red; diff --git a/src/newsreader/scss/components/navbar/_navbar.scss b/src/newsreader/scss/components/navbar/_navbar.scss index d5699ed..0176265 100644 --- a/src/newsreader/scss/components/navbar/_navbar.scss +++ b/src/newsreader/scss/components/navbar/_navbar.scss @@ -3,11 +3,10 @@ justify-content: center; margin: 0 0 5px 0; - padding: 15px 0; + padding: 10px 0; width: 100%; background-color: $white; - box-shadow: 0px 5px darken($azureish-white, +10%); ol { display: flex; @@ -28,7 +27,7 @@ border: none; border-radius: 2px; - background-color: $azureish-white; + background-color: darken($azureish-white, 20%); &:hover{ background-color: lighten($azureish-white, +5%); @@ -36,6 +35,7 @@ & a { @extend .button; + color: $white; } } diff --git a/src/newsreader/scss/components/rules/_rules.scss b/src/newsreader/scss/components/rules/_rules.scss index 029a070..92427da 100644 --- a/src/newsreader/scss/components/rules/_rules.scss +++ b/src/newsreader/scss/components/rules/_rules.scss @@ -16,11 +16,11 @@ &:hover { cursor: pointer; - background-color: darken($azureish-white, +10%); + background-color: $focus-blue; } &--selected { - background-color: darken($azureish-white, +10%); + background-color: $focus-blue; } } diff --git a/src/newsreader/scss/partials/_colors.scss b/src/newsreader/scss/partials/_colors.scss index 664ddf7..8e776a2 100644 --- a/src/newsreader/scss/partials/_colors.scss +++ b/src/newsreader/scss/partials/_colors.scss @@ -1,8 +1,3 @@ -$white: rgba(255, 255, 255, 1); -$black: rgba(0, 0, 0, 1); - -$dark: rgba(0, 0, 0, 0.4); - // light blue $azureish-white: rgba(205, 230, 245, 1); @@ -34,5 +29,11 @@ $confirm-green: $success-green; $cancel-red: $error-red; $border-gray: rgba(227, 227, 227, 1); -$focus-blue: darken($azureish-white, +50%); + +$focus-blue: darken($azureish-white, +10%); $default-font-color: rgba(48, 51, 53, 1); + +$white: rgba(255, 255, 255, 1); +$black: rgba(0, 0, 0, 1); +$blue: darken($azureish-white, +50%); +$dark: rgba(0, 0, 0, 0.4); From 708076b2ab74561073bf4c7b065343e5d06b5cef Mon Sep 17 00:00:00 2001 From: Sonny Date: Wed, 22 Apr 2020 23:04:53 +0200 Subject: [PATCH 078/422] Change collection task to class based task & update behavior --- .../migrations/0008_auto_20200422_2243.py | 21 +++++++++ src/newsreader/accounts/models.py | 2 +- src/newsreader/fixtures/default-fixture.json | 4 +- src/newsreader/news/collection/tasks.py | 43 ++++++++++++++----- 4 files changed, 56 insertions(+), 14 deletions(-) create mode 100644 src/newsreader/accounts/migrations/0008_auto_20200422_2243.py diff --git a/src/newsreader/accounts/migrations/0008_auto_20200422_2243.py b/src/newsreader/accounts/migrations/0008_auto_20200422_2243.py new file mode 100644 index 0000000..6a305f0 --- /dev/null +++ b/src/newsreader/accounts/migrations/0008_auto_20200422_2243.py @@ -0,0 +1,21 @@ +# Generated by Django 3.0.5 on 2020-04-22 20:43 + +from django.db import migrations + +from django_celery_beat.models import PeriodicTask + + +def update_task_name(apps, schema_editor): + old_task = "newsreader.news.collection.tasks.collect" + new_task = "newsreader.news.collection.tasks.FeedTask" + + for task in PeriodicTask.objects.filter(task=old_task): + task.task = new_task + task.save() + + +class Migration(migrations.Migration): + + dependencies = [("accounts", "0007_auto_20191116_1255")] + + operations = [migrations.RunPython(update_task_name)] diff --git a/src/newsreader/accounts/models.py b/src/newsreader/accounts/models.py index 423b97b..0b2799f 100644 --- a/src/newsreader/accounts/models.py +++ b/src/newsreader/accounts/models.py @@ -69,7 +69,7 @@ class User(AbstractUser): enabled=True, interval=task_interval, name=f"{self.email}-collection-task", - task="newsreader.news.collection.tasks.collect", + task="newsreader.news.collection.tasks.FeedTask", args=json.dumps([self.pk]), ) diff --git a/src/newsreader/fixtures/default-fixture.json b/src/newsreader/fixtures/default-fixture.json index e0de28f..7b7ecdf 100644 --- a/src/newsreader/fixtures/default-fixture.json +++ b/src/newsreader/fixtures/default-fixture.json @@ -4,7 +4,7 @@ "pk": 10, "fields": { "name": "sonny@bakker.nl-collection-task", - "task": "newsreader.news.collection.tasks.collect", + "task": "newsreader.news.collection.tasks.FeedTask", "interval": 4, "crontab": null, "solar": null, @@ -31,7 +31,7 @@ "pk": 26, "fields": { "name": "sonnyba871@gmail.com-collection-task", - "task": "newsreader.news.collection.tasks.collect", + "task": "newsreader.news.collection.tasks.FeedTask", "interval": 4, "crontab": null, "solar": null, diff --git a/src/newsreader/news/collection/tasks.py b/src/newsreader/news/collection/tasks.py index b2dbf58..6888cba 100644 --- a/src/newsreader/news/collection/tasks.py +++ b/src/newsreader/news/collection/tasks.py @@ -1,21 +1,42 @@ from django.core.exceptions import ObjectDoesNotExist +from celery.exceptions import Reject +from celery.utils.log import get_task_logger + from newsreader.accounts.models import User from newsreader.celery import app from newsreader.news.collection.feed import FeedCollector from newsreader.utils.celery import MemCacheLock -@app.task(bind=True) -def collect(self, user_pk): - try: - user = User.objects.get(pk=user_pk) - except ObjectDoesNotExist: - return +logger = get_task_logger(__name__) - with MemCacheLock(f"{user.email}-task", self.app.oid) as acquired: - if acquired: - rules = user.rules.all() - collector = FeedCollector() - collector.collect(rules=rules) +class FeedTask(app.Task): + name = "newsreader.news.collection.tasks.FeedTask" + ignore_result = True + + def run(self, user_pk): + try: + user = User.objects.get(pk=user_pk) + except ObjectDoesNotExist: + message = f"User {user_pk} does not exist" + logger.exception(message) + + raise Reject(reason=message, requeue=False) + + with MemCacheLock(f"{user.email}-task", self.app.oid) as acquired: + if acquired: + logger.info(f"Running task for user {user_pk}") + + rules = user.rules.all() + + collector = FeedCollector() + collector.collect(rules=rules) + else: + logger.info(f"Cancelling task due to existing lock for user {user_pk}") + + raise Reject(reason="Task already running", requeue=False) + + +FeedTask = app.register_task(FeedTask()) From e9f05868c1c0b9abcf8d8ab086973996b88b9e63 Mon Sep 17 00:00:00 2001 From: Sonny Date: Wed, 22 Apr 2020 23:11:24 +0200 Subject: [PATCH 079/422] Update data migration --- src/newsreader/accounts/migrations/0008_auto_20200422_2243.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/newsreader/accounts/migrations/0008_auto_20200422_2243.py b/src/newsreader/accounts/migrations/0008_auto_20200422_2243.py index 6a305f0..657245a 100644 --- a/src/newsreader/accounts/migrations/0008_auto_20200422_2243.py +++ b/src/newsreader/accounts/migrations/0008_auto_20200422_2243.py @@ -2,10 +2,10 @@ from django.db import migrations -from django_celery_beat.models import PeriodicTask - def update_task_name(apps, schema_editor): + PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask") + old_task = "newsreader.news.collection.tasks.collect" new_task = "newsreader.news.collection.tasks.FeedTask" From b811d1945b3d3dc5f087c2a1f6295ccc504473bf Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 26 Apr 2020 21:06:29 +0200 Subject: [PATCH 080/422] Update logging --- src/newsreader/celery.py | 6 +--- src/newsreader/conf/base.py | 67 +++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/src/newsreader/celery.py b/src/newsreader/celery.py index aa15a08..3eb59e0 100644 --- a/src/newsreader/celery.py +++ b/src/newsreader/celery.py @@ -3,12 +3,8 @@ import os from celery import Celery -# note: this should be consistent with the setting from manage.py os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.dev") -# note: use the --workdir flag when running from different directories app = Celery("newsreader") - -app.config_from_object("django.conf:settings") - +app.config_from_object("django.conf:settings", namespace="CELERY") app.autodiscover_tasks() diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 743973d..3692deb 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -94,6 +94,69 @@ CACHES = { }, } +# Logging +# https://docs.djangoproject.com/en/2.2/topics/logging/#configuring-logging +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "filters": { + "require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}, + "require_debug_true": {"()": "django.utils.log.RequireDebugTrue"}, + }, + "formatters": { + "django.server": { + "()": "django.utils.log.ServerFormatter", + "format": "[{server_time}] {message}", + "style": "{", + }, + "syslog": {"class": "logging.Formatter", "format": "{message}", "style": "{"}, + }, + "handlers": { + "console": { + "level": "INFO", + "filters": ["require_debug_true"], + "class": "logging.StreamHandler", + }, + "django.server": { + "level": "INFO", + "class": "logging.StreamHandler", + "formatter": "django.server", + }, + "mail_admins": { + "level": "ERROR", + "filters": ["require_debug_false"], + "class": "django.utils.log.AdminEmailHandler", + }, + "syslog": { + "level": "INFO", + "filters": ["require_debug_false"], + "class": "logging.handlers.SysLogHandler", + "formatter": "syslog", + "address": "/dev/log", + }, + "syslog_errors": { + "level": "ERROR", + "filters": ["require_debug_false"], + "class": "logging.handlers.SysLogHandler", + "formatter": "syslog", + "address": "/dev/log", + }, + }, + "loggers": { + "django": { + "handlers": ["console", "mail_admins", "syslog_errors"], + "level": "INFO", + }, + "django.server": { + "handlers": ["django.server"], + "level": "INFO", + "propagate": False, + }, + "celery": {"handlers": ["syslog", "console"], "level": "INFO"}, + "celery.task": {"handlers": ["syslog", "console"], "level": "INFO"}, + }, +} + # Password validation # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ @@ -155,6 +218,10 @@ SWAGGER_SETTINGS = { "DOC_EXPANSION": "list", } +# Celery +# https://docs.celeryproject.org/en/stable/userguide/configuration.html +CELERY_WORKER_HIJACK_ROOT_LOGGER = False + REGISTRATION_OPEN = True REGISTRATION_AUTO_LOGIN = True ACCOUNT_ACTIVATION_DAYS = 7 From 62da6e0d8e622bbd21a3502a0619e54fd5f68e79 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 26 Apr 2020 21:12:02 +0200 Subject: [PATCH 081/422] Fix wrong axes handler --- src/newsreader/conf/production.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/newsreader/conf/production.py b/src/newsreader/conf/production.py index f287498..3e3369d 100644 --- a/src/newsreader/conf/production.py +++ b/src/newsreader/conf/production.py @@ -40,6 +40,6 @@ TEMPLATES = [ ] # Third party settings -AXES_HANDLER = "axes.handlers.database.DatabaseHandler" +AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler" REGISTRATION_OPEN = False From 57e9073f6b79905c10d72eb2e5d310dbeacc195c Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 26 Apr 2020 21:17:55 +0200 Subject: [PATCH 082/422] Allow admins to be specified through env variables --- src/newsreader/conf/production.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/newsreader/conf/production.py b/src/newsreader/conf/production.py index 3e3369d..b5c766a 100644 --- a/src/newsreader/conf/production.py +++ b/src/newsreader/conf/production.py @@ -9,7 +9,13 @@ from .base import * # isort:skip load_dotenv() DEBUG = False + ALLOWED_HOSTS = ["rss.fudiggity.nl"] +ADMINS = [ + ("", email) + for email in os.getenv("ADMINS", "").split(",") + if os.environ.get("ADMINS") +] SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] From 7fc899937dac26994d37e5785f3d2ba21123703c Mon Sep 17 00:00:00 2001 From: sonny Date: Sat, 2 May 2020 20:21:22 +0200 Subject: [PATCH 083/422] Refactor much needed docker setup - Build static files inside seperate container - Remove unnecessary env variables --- docker-compose.yml | 64 +++++++++++++++++++---------------- Dockerfile => docker/django | 3 +- docker/webpack | 9 +++++ src/entrypoint.sh | 4 +-- src/newsreader/conf/docker.py | 16 +++++++-- 5 files changed, 59 insertions(+), 37 deletions(-) rename Dockerfile => docker/django (59%) create mode 100644 docker/webpack diff --git a/docker-compose.yml b/docker-compose.yml index 7a39a3f..e168162 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,52 +1,56 @@ version: '3' +volumes: + postgres-data: + static-files: + node-modules: services: db: - # See https://hub.docker.com/_/postgres image: postgres - container_name: postgres environment: - - POSTGRES_DB=$POSTGRES_NAME - - POSTGRES_USER=$POSTGRES_USER - - POSTGRES_PASSWORD=$POSTGRES_PASSWORD + POSTGRES_DB: "newsreader" + POSTGRES_USER: "newsreader" + POSTGRES_PASSWORD: "newsreader" + volumes: + - postgres-data:/var/lib/postgresql/data rabbitmq: image: rabbitmq:3.7 - container_name: rabbitmq - celery: - build: . - container_name: celery - command: poetry run celery -A newsreader worker -l INFO --beat --scheduler django --workdir=/app/src/ - environment: - - POSTGRES_HOST=$POSTGRES_HOST - - POSTGRES_NAME=$POSTGRES_NAME - - POSTGRES_USER=$POSTGRES_USER - - POSTGRES_PASSWORD=$POSTGRES_PASSWORD - - DJANGO_SETTINGS_MODULE=newsreader.conf.docker - volumes: - - .:/app - depends_on: - - rabbitmq memcached: image: memcached:1.5.22 - container_name: memcached ports: - "11211:11211" entrypoint: - memcached - -m 64 - web: - build: . - container_name: web + celery: + build: + context: . + dockerfile: ./docker/django + command: celery worker --app newsreader --loglevel INFO --beat --scheduler django --workdir /app/src/ + environment: + - DJANGO_SETTINGS_MODULE=newsreader.conf.docker + depends_on: + - rabbitmq + django: + build: + context: . + dockerfile: ./docker/django command: src/entrypoint.sh environment: - - POSTGRES_HOST=$POSTGRES_HOST - - POSTGRES_NAME=$POSTGRES_NAME - - POSTGRES_USER=$POSTGRES_USER - - POSTGRES_PASSWORD=$POSTGRES_PASSWORD - DJANGO_SETTINGS_MODULE=newsreader.conf.docker - volumes: - - .:/app ports: - '8000:8000' depends_on: - db + volumes: + - .:/app + - static-files:/app/src/newsreader/static + webpack: + build: + context: . + dockerfile: ./docker/webpack + command: npm run build:watch + volumes: + - .:/app + - static-files:/app/src/newsreader/static + - node-modules:/app/node_modules diff --git a/Dockerfile b/docker/django similarity index 59% rename from Dockerfile rename to docker/django index 61ef10b..871828a 100644 --- a/Dockerfile +++ b/docker/django @@ -5,7 +5,6 @@ RUN pip install poetry WORKDIR /app COPY poetry.lock pyproject.toml /app/ -RUN poetry config virtualenvs.create false -RUN poetry install --no-interaction +RUN poetry config virtualenvs.create false && poetry install --no-interaction COPY . /app/ diff --git a/docker/webpack b/docker/webpack new file mode 100644 index 0000000..6909ee9 --- /dev/null +++ b/docker/webpack @@ -0,0 +1,9 @@ +FROM node:12 + +WORKDIR /app + +COPY package.json package-lock.json /app/ + +RUN npm install + +COPY . /app/ diff --git a/src/entrypoint.sh b/src/entrypoint.sh index 451b7d3..3fbf941 100755 --- a/src/entrypoint.sh +++ b/src/entrypoint.sh @@ -1,5 +1,5 @@ #!/bin/bash # This file should only be used in conjuction with docker-compose -poetry run /app/src/manage.py migrate -poetry run /app/src/manage.py runserver 0.0.0.0:8000 +python /app/src/manage.py migrate +python /app/src/manage.py runserver 0.0.0.0:8000 diff --git a/src/newsreader/conf/docker.py b/src/newsreader/conf/docker.py index 3584b30..dd2471f 100644 --- a/src/newsreader/conf/docker.py +++ b/src/newsreader/conf/docker.py @@ -3,9 +3,15 @@ from .dev import * # isort:skip SECRET_KEY = "=q(ztyo)b6noom#a164g&s9vcj1aawa^g#ing_ir99=_zl4g&$" -# Celery -# https://docs.celeryproject.org/en/latest/userguide/configuration.html -BROKER_URL = "amqp://guest:guest@rabbitmq:5672//" +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "newsreader", + "USER": "newsreader", + "PASSWORD": "newsreader", + "HOST": "db", + } +} CACHES = { "default": { @@ -17,3 +23,7 @@ CACHES = { "LOCATION": "memcached:11211", }, } + +# Celery +# https://docs.celeryproject.org/en/latest/userguide/configuration.html +CELERY_BROKER_URL = "amqp://guest:guest@rabbitmq:5672//" From bec3488e633d6f0166bc17587b0bf375020ab498 Mon Sep 17 00:00:00 2001 From: sonny Date: Sun, 10 May 2020 20:11:12 +0200 Subject: [PATCH 084/422] Resolve "Feeds list view" --- src/newsreader/fixtures/default-fixture.json | 2571 ++++++++++++++++- src/newsreader/js/components/Selector.js | 23 + src/newsreader/js/index.js | 2 +- src/newsreader/js/pages/rules/App.js | 106 - .../js/pages/rules/components/RuleCard.js | 65 - .../js/pages/rules/components/RuleModal.js | 35 - src/newsreader/js/pages/rules/index.js | 10 +- src/newsreader/news/collection/forms.py | 11 + .../migrations/0007_collectionrule_enabled.py | 18 + src/newsreader/news/collection/models.py | 7 +- .../templates/collection/rules.html | 85 +- .../collection/tests/views/test_bulk_views.py | 244 ++ .../{test_views.py => views/test_crud.py} | 132 - .../tests/views/test_import_view.py | 141 + src/newsreader/news/collection/urls.py | 18 + src/newsreader/news/collection/views.py | 66 +- .../scss/components/form/_form.scss | 8 + .../scss/components/form/_rules-form.scss | 5 + .../scss/components/form/index.scss | 1 + src/newsreader/scss/components/index.scss | 2 + .../components/pagination/_pagination.scss | 18 + .../scss/components/pagination/index.scss | 1 + .../scss/components/table/_rules-table.scss | 38 + .../scss/components/table/_table.scss | 32 + .../scss/components/table/index.scss | 2 + .../scss/elements/button/_button.scss | 4 +- .../scss/elements/button/_mixins.scss | 3 + src/newsreader/scss/elements/link/_link.scss | 4 - src/newsreader/scss/lib/_css.gg.scss | 8 + src/newsreader/scss/lib/_mixins.scss | 3 + src/newsreader/scss/pages/index.scss | 2 +- src/newsreader/scss/pages/rules/index.scss | 6 +- 32 files changed, 3199 insertions(+), 472 deletions(-) create mode 100644 src/newsreader/js/components/Selector.js delete mode 100644 src/newsreader/js/pages/rules/App.js delete mode 100644 src/newsreader/js/pages/rules/components/RuleCard.js delete mode 100644 src/newsreader/js/pages/rules/components/RuleModal.js create mode 100644 src/newsreader/news/collection/migrations/0007_collectionrule_enabled.py create mode 100644 src/newsreader/news/collection/tests/views/test_bulk_views.py rename src/newsreader/news/collection/tests/{test_views.py => views/test_crud.py} (52%) create mode 100644 src/newsreader/news/collection/tests/views/test_import_view.py create mode 100644 src/newsreader/scss/components/form/_rules-form.scss create mode 100644 src/newsreader/scss/components/pagination/_pagination.scss create mode 100644 src/newsreader/scss/components/pagination/index.scss create mode 100644 src/newsreader/scss/components/table/_rules-table.scss create mode 100644 src/newsreader/scss/components/table/_table.scss create mode 100644 src/newsreader/scss/components/table/index.scss create mode 100644 src/newsreader/scss/elements/button/_mixins.scss create mode 100644 src/newsreader/scss/lib/_mixins.scss diff --git a/src/newsreader/fixtures/default-fixture.json b/src/newsreader/fixtures/default-fixture.json index 7b7ecdf..a6a9162 100644 --- a/src/newsreader/fixtures/default-fixture.json +++ b/src/newsreader/fixtures/default-fixture.json @@ -1,68 +1,143 @@ [ { - "model": "django_celery_beat.periodictask", - "pk": 10, + "model": "contenttypes.contenttype", "fields": { - "name": "sonny@bakker.nl-collection-task", - "task": "newsreader.news.collection.tasks.FeedTask", - "interval": 4, - "crontab": null, - "solar": null, - "clocked": null, - "args": "[2]", - "kwargs": "{}", - "queue": null, - "exchange": null, - "routing_key": null, - "headers": "{}", - "priority": null, - "expires": null, - "one_off": false, - "start_time": null, - "enabled": true, - "last_run_at": "2019-11-29T22:29:08.345Z", - "total_run_count": 290, - "date_changed": "2019-11-29T22:29:18.378Z", - "description": "" + "app_label": "admin", + "model": "logentry" } }, { - "model": "django_celery_beat.periodictask", - "pk": 26, + "model": "contenttypes.contenttype", "fields": { - "name": "sonnyba871@gmail.com-collection-task", - "task": "newsreader.news.collection.tasks.FeedTask", - "interval": 4, - "crontab": null, - "solar": null, - "clocked": null, - "args": "[18]", - "kwargs": "{}", - "queue": null, - "exchange": null, - "routing_key": null, - "headers": "{}", - "priority": null, - "expires": null, - "one_off": false, - "start_time": null, - "enabled": true, - "last_run_at": "2019-11-29T22:35:19.134Z", - "total_run_count": 103, - "date_changed": "2019-11-29T22:38:19.464Z", - "description": "" + "app_label": "auth", + "model": "permission" } }, { - "model": "django_celery_beat.crontabschedule", - "pk": 1, + "model": "contenttypes.contenttype", "fields": { - "minute": "0", - "hour": "4", - "day_of_week": "*", - "day_of_month": "*", - "month_of_year": "*", - "timezone": "UTC" + "app_label": "auth", + "model": "group" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "contenttypes", + "model": "contenttype" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "sessions", + "model": "session" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "crontabschedule" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "intervalschedule" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "periodictask" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "periodictasks" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "solarschedule" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "clockedschedule" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "registration", + "model": "registrationprofile" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "registration", + "model": "supervisedregistrationprofile" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "axes", + "model": "accessattempt" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "axes", + "model": "accesslog" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "accounts", + "model": "user" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "core", + "model": "post" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "core", + "model": "category" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "collection", + "model": "collectionrule" + } +}, +{ + "model": "sessions.session", + "pk": "3sumq22krk8tsvexcs4b8czu82yhvuer", + "fields": { + "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", + "expire_date": "2020-05-16T18:29:04.049Z" } }, { @@ -97,11 +172,950 @@ "period": "hours" } }, +{ + "model": "django_celery_beat.crontabschedule", + "pk": 1, + "fields": { + "minute": "0", + "hour": "4", + "day_of_week": "*", + "day_of_month": "*", + "month_of_year": "*", + "timezone": "UTC" + } +}, +{ + "model": "django_celery_beat.periodictasks", + "pk": 1, + "fields": { + "last_update": "2020-05-02T20:40:29.029Z" + } +}, +{ + "model": "django_celery_beat.periodictask", + "pk": 1, + "fields": { + "name": "celery.backend_cleanup", + "task": "celery.backend_cleanup", + "interval": null, + "crontab": 1, + "solar": null, + "clocked": null, + "args": "[]", + "kwargs": "{}", + "queue": null, + "exchange": null, + "routing_key": null, + "headers": "{}", + "priority": null, + "expires": null, + "expire_seconds": 43200, + "one_off": false, + "start_time": null, + "enabled": true, + "last_run_at": null, + "total_run_count": 0, + "date_changed": "2020-05-02T20:06:23.985Z", + "description": "" + } +}, +{ + "model": "django_celery_beat.periodictask", + "pk": 10, + "fields": { + "name": "sonny@bakker.nl-collection-task", + "task": "newsreader.news.collection.tasks.FeedTask", + "interval": 4, + "crontab": null, + "solar": null, + "clocked": null, + "args": "[2]", + "kwargs": "{}", + "queue": null, + "exchange": null, + "routing_key": null, + "headers": "{}", + "priority": null, + "expires": null, + "expire_seconds": null, + "one_off": false, + "start_time": null, + "enabled": true, + "last_run_at": "2020-05-02T20:06:24.012Z", + "total_run_count": 292, + "date_changed": "2020-05-02T20:06:24.027Z", + "description": "" + } +}, +{ + "model": "django_celery_beat.periodictask", + "pk": 26, + "fields": { + "name": "sonnyba871@gmail.com-collection-task", + "task": "newsreader.news.collection.tasks.FeedTask", + "interval": 4, + "crontab": null, + "solar": null, + "clocked": null, + "args": "[18]", + "kwargs": "{}", + "queue": null, + "exchange": null, + "routing_key": null, + "headers": "{}", + "priority": null, + "expires": null, + "expire_seconds": null, + "one_off": false, + "start_time": null, + "enabled": true, + "last_run_at": "2020-05-02T20:06:24.045Z", + "total_run_count": 105, + "date_changed": "2020-05-02T20:09:24.331Z", + "description": "" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add log entry", + "content_type": [ + "admin", + "logentry" + ], + "codename": "add_logentry" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change log entry", + "content_type": [ + "admin", + "logentry" + ], + "codename": "change_logentry" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete log entry", + "content_type": [ + "admin", + "logentry" + ], + "codename": "delete_logentry" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view log entry", + "content_type": [ + "admin", + "logentry" + ], + "codename": "view_logentry" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add permission", + "content_type": [ + "auth", + "permission" + ], + "codename": "add_permission" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change permission", + "content_type": [ + "auth", + "permission" + ], + "codename": "change_permission" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete permission", + "content_type": [ + "auth", + "permission" + ], + "codename": "delete_permission" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view permission", + "content_type": [ + "auth", + "permission" + ], + "codename": "view_permission" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add group", + "content_type": [ + "auth", + "group" + ], + "codename": "add_group" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change group", + "content_type": [ + "auth", + "group" + ], + "codename": "change_group" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete group", + "content_type": [ + "auth", + "group" + ], + "codename": "delete_group" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view group", + "content_type": [ + "auth", + "group" + ], + "codename": "view_group" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add content type", + "content_type": [ + "contenttypes", + "contenttype" + ], + "codename": "add_contenttype" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change content type", + "content_type": [ + "contenttypes", + "contenttype" + ], + "codename": "change_contenttype" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete content type", + "content_type": [ + "contenttypes", + "contenttype" + ], + "codename": "delete_contenttype" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view content type", + "content_type": [ + "contenttypes", + "contenttype" + ], + "codename": "view_contenttype" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add session", + "content_type": [ + "sessions", + "session" + ], + "codename": "add_session" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change session", + "content_type": [ + "sessions", + "session" + ], + "codename": "change_session" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete session", + "content_type": [ + "sessions", + "session" + ], + "codename": "delete_session" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view session", + "content_type": [ + "sessions", + "session" + ], + "codename": "view_session" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add crontab", + "content_type": [ + "django_celery_beat", + "crontabschedule" + ], + "codename": "add_crontabschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change crontab", + "content_type": [ + "django_celery_beat", + "crontabschedule" + ], + "codename": "change_crontabschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete crontab", + "content_type": [ + "django_celery_beat", + "crontabschedule" + ], + "codename": "delete_crontabschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view crontab", + "content_type": [ + "django_celery_beat", + "crontabschedule" + ], + "codename": "view_crontabschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add interval", + "content_type": [ + "django_celery_beat", + "intervalschedule" + ], + "codename": "add_intervalschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change interval", + "content_type": [ + "django_celery_beat", + "intervalschedule" + ], + "codename": "change_intervalschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete interval", + "content_type": [ + "django_celery_beat", + "intervalschedule" + ], + "codename": "delete_intervalschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view interval", + "content_type": [ + "django_celery_beat", + "intervalschedule" + ], + "codename": "view_intervalschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add periodic task", + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "codename": "add_periodictask" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change periodic task", + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "codename": "change_periodictask" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete periodic task", + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "codename": "delete_periodictask" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view periodic task", + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "codename": "view_periodictask" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add periodic tasks", + "content_type": [ + "django_celery_beat", + "periodictasks" + ], + "codename": "add_periodictasks" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change periodic tasks", + "content_type": [ + "django_celery_beat", + "periodictasks" + ], + "codename": "change_periodictasks" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete periodic tasks", + "content_type": [ + "django_celery_beat", + "periodictasks" + ], + "codename": "delete_periodictasks" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view periodic tasks", + "content_type": [ + "django_celery_beat", + "periodictasks" + ], + "codename": "view_periodictasks" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add solar event", + "content_type": [ + "django_celery_beat", + "solarschedule" + ], + "codename": "add_solarschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change solar event", + "content_type": [ + "django_celery_beat", + "solarschedule" + ], + "codename": "change_solarschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete solar event", + "content_type": [ + "django_celery_beat", + "solarschedule" + ], + "codename": "delete_solarschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view solar event", + "content_type": [ + "django_celery_beat", + "solarschedule" + ], + "codename": "view_solarschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add clocked", + "content_type": [ + "django_celery_beat", + "clockedschedule" + ], + "codename": "add_clockedschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change clocked", + "content_type": [ + "django_celery_beat", + "clockedschedule" + ], + "codename": "change_clockedschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete clocked", + "content_type": [ + "django_celery_beat", + "clockedschedule" + ], + "codename": "delete_clockedschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view clocked", + "content_type": [ + "django_celery_beat", + "clockedschedule" + ], + "codename": "view_clockedschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add registration profile", + "content_type": [ + "registration", + "registrationprofile" + ], + "codename": "add_registrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change registration profile", + "content_type": [ + "registration", + "registrationprofile" + ], + "codename": "change_registrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete registration profile", + "content_type": [ + "registration", + "registrationprofile" + ], + "codename": "delete_registrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view registration profile", + "content_type": [ + "registration", + "registrationprofile" + ], + "codename": "view_registrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add supervised registration profile", + "content_type": [ + "registration", + "supervisedregistrationprofile" + ], + "codename": "add_supervisedregistrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change supervised registration profile", + "content_type": [ + "registration", + "supervisedregistrationprofile" + ], + "codename": "change_supervisedregistrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete supervised registration profile", + "content_type": [ + "registration", + "supervisedregistrationprofile" + ], + "codename": "delete_supervisedregistrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view supervised registration profile", + "content_type": [ + "registration", + "supervisedregistrationprofile" + ], + "codename": "view_supervisedregistrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add access attempt", + "content_type": [ + "axes", + "accessattempt" + ], + "codename": "add_accessattempt" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change access attempt", + "content_type": [ + "axes", + "accessattempt" + ], + "codename": "change_accessattempt" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete access attempt", + "content_type": [ + "axes", + "accessattempt" + ], + "codename": "delete_accessattempt" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view access attempt", + "content_type": [ + "axes", + "accessattempt" + ], + "codename": "view_accessattempt" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add access log", + "content_type": [ + "axes", + "accesslog" + ], + "codename": "add_accesslog" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change access log", + "content_type": [ + "axes", + "accesslog" + ], + "codename": "change_accesslog" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete access log", + "content_type": [ + "axes", + "accesslog" + ], + "codename": "delete_accesslog" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view access log", + "content_type": [ + "axes", + "accesslog" + ], + "codename": "view_accesslog" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add user", + "content_type": [ + "accounts", + "user" + ], + "codename": "add_user" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change user", + "content_type": [ + "accounts", + "user" + ], + "codename": "change_user" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete user", + "content_type": [ + "accounts", + "user" + ], + "codename": "delete_user" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view user", + "content_type": [ + "accounts", + "user" + ], + "codename": "view_user" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add post", + "content_type": [ + "core", + "post" + ], + "codename": "add_post" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change post", + "content_type": [ + "core", + "post" + ], + "codename": "change_post" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete post", + "content_type": [ + "core", + "post" + ], + "codename": "delete_post" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view post", + "content_type": [ + "core", + "post" + ], + "codename": "view_post" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add Category", + "content_type": [ + "core", + "category" + ], + "codename": "add_category" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change Category", + "content_type": [ + "core", + "category" + ], + "codename": "change_category" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete Category", + "content_type": [ + "core", + "category" + ], + "codename": "delete_category" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view Category", + "content_type": [ + "core", + "category" + ], + "codename": "view_category" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add collection rule", + "content_type": [ + "collection", + "collectionrule" + ], + "codename": "add_collectionrule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change collection rule", + "content_type": [ + "collection", + "collectionrule" + ], + "codename": "change_collectionrule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete collection rule", + "content_type": [ + "collection", + "collectionrule" + ], + "codename": "delete_collectionrule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view collection rule", + "content_type": [ + "collection", + "collectionrule" + ], + "codename": "view_collectionrule" + } +}, { "model": "accounts.user", "fields": { - "password": "pbkdf2_sha256$150000$5lBD7JemxYfE$B+lM5wWUW2n/ZulPFaWHtzWjyQ/QZ6iwjAC2I0R/VzU=", - "last_login": "2019-11-27T18:57:36.686Z", + "password": "pbkdf2_sha256$180000$KGKGsPnSwyiN$RqQAD46r4Kzqndqp5dmpj+H/drDrPRI0r6j4gLtYBjE=", + "last_login": "2020-05-02T18:29:04.047Z", "is_superuser": true, "first_name": "", "last_name": "", @@ -114,23 +1128,6 @@ "user_permissions": [] } }, -{ - "model": "accounts.user", - "fields": { - "password": "pbkdf2_sha256$150000$vUwxT8T25R8C$S+Eq2tMRbSDE31/X5KGJ/M+Nblh7kKfzuM/z7HraR/Q=", - "last_login": null, - "is_superuser": false, - "first_name": "", - "last_name": "", - "is_staff": false, - "is_active": false, - "date_joined": "2019-11-25T15:35:14.051Z", - "email": "sonnyba871@gmail.com", - "task": 26, - "groups": [], - "user_permissions": [] - } -}, { "model": "core.category", "pk": 8, @@ -160,14 +1157,14 @@ "pk": 3, "fields": { "created": "2019-07-14T13:08:10.374Z", - "modified": "2019-11-29T22:35:20.346Z", + "modified": "2020-05-02T20:06:25.841Z", "name": "Hackers News", "url": "https://news.ycombinator.com/rss", "website_url": "https://news.ycombinator.com/", "favicon": "https://news.ycombinator.com/favicon.ico", "timezone": "UTC", "category": 9, - "last_suceeded": "2019-11-29T22:35:20.235Z", + "last_suceeded": "2020-05-02T20:06:25.793Z", "succeeded": true, "error": null, "user": [ @@ -180,14 +1177,14 @@ "pk": 4, "fields": { "created": "2019-07-20T11:24:32.745Z", - "modified": "2019-11-29T22:35:19.525Z", + "modified": "2020-05-02T20:06:24.719Z", "name": "BBC", "url": "http://feeds.bbci.co.uk/news/world/rss.xml", "website_url": "https://www.bbc.co.uk/news/", "favicon": "https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png", "timezone": "UTC", "category": 8, - "last_suceeded": "2019-11-29T22:35:19.241Z", + "last_suceeded": "2020-05-02T20:06:24.128Z", "succeeded": true, "error": null, "user": [ @@ -200,14 +1197,14 @@ "pk": 5, "fields": { "created": "2019-07-20T11:24:50.411Z", - "modified": "2019-11-29T22:35:20.010Z", + "modified": "2020-05-02T20:06:25.548Z", "name": "Ars Technica", "url": "http://feeds.arstechnica.com/arstechnica/index?fmt=xml", "website_url": "https://arstechnica.com", "favicon": "https://cdn.arstechnica.net/favicon.ico", "timezone": "UTC", "category": 9, - "last_suceeded": "2019-11-29T22:35:19.808Z", + "last_suceeded": "2020-05-02T20:06:25.364Z", "succeeded": true, "error": null, "user": [ @@ -220,14 +1217,14 @@ "pk": 6, "fields": { "created": "2019-07-20T11:25:02.089Z", - "modified": "2019-11-29T22:35:20.233Z", + "modified": "2020-05-02T20:06:25.741Z", "name": "The Guardian", "url": "https://www.theguardian.com/world/rss", "website_url": "https://www.theguardian.com/world", "favicon": "https://assets.guim.co.uk/images/favicons/873381bf11d58e20f551905d51575117/72x72.png", "timezone": "UTC", "category": 8, - "last_suceeded": "2019-11-29T22:35:20.076Z", + "last_suceeded": "2020-05-02T20:06:25.620Z", "succeeded": true, "error": null, "user": [ @@ -240,14 +1237,14 @@ "pk": 7, "fields": { "created": "2019-07-20T11:25:30.121Z", - "modified": "2019-11-29T22:35:19.695Z", + "modified": "2020-05-02T20:06:25.352Z", "name": "Tweakers", "url": "http://feeds.feedburner.com/tweakers/mixed?fmt=xml", "website_url": "https://tweakers.net/", "favicon": null, "timezone": "UTC", "category": 9, - "last_suceeded": "2019-11-29T22:35:19.528Z", + "last_suceeded": "2020-05-02T20:06:24.730Z", "succeeded": true, "error": null, "user": [ @@ -260,14 +1257,14 @@ "pk": 8, "fields": { "created": "2019-07-20T11:25:46.256Z", - "modified": "2019-11-29T22:35:20.074Z", + "modified": "2020-05-02T20:06:25.792Z", "name": "The Verge", "url": "https://www.theverge.com/rss/index.xml", "website_url": "https://www.theverge.com/", "favicon": "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395367/favicon-16x16.0.png", "timezone": "UTC", "category": 9, - "last_suceeded": "2019-11-29T22:35:20.012Z", + "last_suceeded": "2020-05-02T20:06:25.742Z", "succeeded": true, "error": null, "user": [ @@ -280,19 +1277,1419 @@ "pk": 9, "fields": { "created": "2019-11-24T15:28:41.399Z", - "modified": "2019-11-29T22:35:19.807Z", + "modified": "2020-05-02T20:06:25.619Z", "name": "NOS", "url": "http://feeds.nos.nl/nosnieuwsalgemeen", "website_url": null, "favicon": null, "timezone": "Europe/Amsterdam", "category": 8, - "last_suceeded": "2019-11-29T22:35:19.697Z", + "last_suceeded": "2020-05-02T20:06:25.549Z", "succeeded": true, "error": null, "user": [ "sonny@bakker.nl" ] } +}, +{ + "model": "collection.collectionrule", + "pk": 10, + "fields": { + "created": "2020-05-02T20:32:34.107Z", + "modified": "2020-05-02T20:32:34.107Z", + "name": "CollectionRule-0", + "url": "http://rasmussen-guerra.com/", + "website_url": "https://ritter.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 11, + "fields": { + "created": "2020-05-02T20:32:34.164Z", + "modified": "2020-05-02T20:32:34.164Z", + "name": "CollectionRule-1", + "url": "https://www.evans.com/", + "website_url": "https://taylor.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 12, + "fields": { + "created": "2020-05-02T20:32:34.220Z", + "modified": "2020-05-02T20:32:34.220Z", + "name": "CollectionRule-2", + "url": "http://weaver-quinn.net/", + "website_url": "https://www.mcintyre.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 13, + "fields": { + "created": "2020-05-02T20:32:34.277Z", + "modified": "2020-05-02T20:32:34.277Z", + "name": "CollectionRule-3", + "url": "http://www.palmer.com/", + "website_url": "http://www.riggs.org/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 14, + "fields": { + "created": "2020-05-02T20:32:34.333Z", + "modified": "2020-05-02T20:32:34.333Z", + "name": "CollectionRule-4", + "url": "http://moody-stein.net/", + "website_url": "https://www.lewis.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 15, + "fields": { + "created": "2020-05-02T20:32:34.390Z", + "modified": "2020-05-02T20:32:34.391Z", + "name": "CollectionRule-5", + "url": "http://www.ochoa.com/", + "website_url": "https://brown.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 16, + "fields": { + "created": "2020-05-02T20:32:34.448Z", + "modified": "2020-05-02T20:32:34.448Z", + "name": "CollectionRule-6", + "url": "https://www.pearson.biz/", + "website_url": "http://acosta-johnson.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 17, + "fields": { + "created": "2020-05-02T20:32:34.506Z", + "modified": "2020-05-02T20:32:34.506Z", + "name": "CollectionRule-7", + "url": "https://jones.com/", + "website_url": "https://www.thornton.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 18, + "fields": { + "created": "2020-05-02T20:32:34.562Z", + "modified": "2020-05-02T20:32:34.562Z", + "name": "CollectionRule-8", + "url": "http://www.matthews-graves.com/", + "website_url": "http://stewart.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 19, + "fields": { + "created": "2020-05-02T20:32:34.618Z", + "modified": "2020-05-02T20:32:34.618Z", + "name": "CollectionRule-9", + "url": "http://www.kelly-martinez.com/", + "website_url": "https://www.freeman.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 20, + "fields": { + "created": "2020-05-02T20:32:34.674Z", + "modified": "2020-05-02T20:32:34.674Z", + "name": "CollectionRule-10", + "url": "https://www.roberts.biz/", + "website_url": "http://www.lopez.info/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 21, + "fields": { + "created": "2020-05-02T20:32:34.730Z", + "modified": "2020-05-02T20:32:34.730Z", + "name": "CollectionRule-11", + "url": "https://www.holmes-cross.com/", + "website_url": "https://www.ramirez.net/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 22, + "fields": { + "created": "2020-05-02T20:32:34.786Z", + "modified": "2020-05-02T20:32:34.786Z", + "name": "CollectionRule-12", + "url": "https://www.jenkins.com/", + "website_url": "https://www.faulkner.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 23, + "fields": { + "created": "2020-05-02T20:32:34.841Z", + "modified": "2020-05-02T20:32:34.842Z", + "name": "CollectionRule-13", + "url": "https://www.adkins.com/", + "website_url": "https://www.munoz-brown.info/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 24, + "fields": { + "created": "2020-05-02T20:32:34.897Z", + "modified": "2020-05-02T20:32:34.898Z", + "name": "CollectionRule-14", + "url": "https://www.rodriguez-ortega.biz/", + "website_url": "http://www.santos.info/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 25, + "fields": { + "created": "2020-05-02T20:32:34.953Z", + "modified": "2020-05-02T20:32:34.954Z", + "name": "CollectionRule-15", + "url": "https://www.hawkins-stewart.com/", + "website_url": "http://www.jones.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 26, + "fields": { + "created": "2020-05-02T20:32:35.010Z", + "modified": "2020-05-02T20:32:35.010Z", + "name": "CollectionRule-16", + "url": "http://mullins.net/", + "website_url": "https://www.curtis.org/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 27, + "fields": { + "created": "2020-05-02T20:32:35.067Z", + "modified": "2020-05-02T20:32:35.067Z", + "name": "CollectionRule-17", + "url": "http://frederick.com/", + "website_url": "https://www.fowler.info/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 28, + "fields": { + "created": "2020-05-02T20:32:35.124Z", + "modified": "2020-05-02T20:32:35.124Z", + "name": "CollectionRule-18", + "url": "http://schmidt.com/", + "website_url": "http://bryant-hoffman.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 29, + "fields": { + "created": "2020-05-02T20:32:35.180Z", + "modified": "2020-05-02T20:32:35.180Z", + "name": "CollectionRule-19", + "url": "https://www.jones.net/", + "website_url": "http://benjamin.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 30, + "fields": { + "created": "2020-05-02T20:32:35.237Z", + "modified": "2020-05-02T20:32:35.237Z", + "name": "CollectionRule-20", + "url": "https://www.parker-lewis.com/", + "website_url": "http://www.anderson.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 31, + "fields": { + "created": "2020-05-02T20:32:35.294Z", + "modified": "2020-05-02T20:32:35.294Z", + "name": "CollectionRule-21", + "url": "http://martinez.com/", + "website_url": "http://burton-scott.biz/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 32, + "fields": { + "created": "2020-05-02T20:32:35.350Z", + "modified": "2020-05-02T20:32:35.350Z", + "name": "CollectionRule-22", + "url": "https://gibbs.com/", + "website_url": "https://www.robertson.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 33, + "fields": { + "created": "2020-05-02T20:32:35.407Z", + "modified": "2020-05-02T20:32:35.407Z", + "name": "CollectionRule-23", + "url": "http://www.fisher.com/", + "website_url": "https://mcclure-miller.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 34, + "fields": { + "created": "2020-05-02T20:32:35.463Z", + "modified": "2020-05-02T20:32:35.463Z", + "name": "CollectionRule-24", + "url": "https://schneider-lopez.org/", + "website_url": "https://andrews-williams.biz/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 35, + "fields": { + "created": "2020-05-02T20:32:35.522Z", + "modified": "2020-05-02T20:32:35.522Z", + "name": "CollectionRule-25", + "url": "http://www.rogers.info/", + "website_url": "https://www.petersen-stewart.biz/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 36, + "fields": { + "created": "2020-05-02T20:32:35.581Z", + "modified": "2020-05-02T20:32:35.581Z", + "name": "CollectionRule-26", + "url": "http://torres.com/", + "website_url": "https://hart-tapia.org/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 37, + "fields": { + "created": "2020-05-02T20:32:35.637Z", + "modified": "2020-05-02T20:32:35.638Z", + "name": "CollectionRule-27", + "url": "http://www.pham-scott.com/", + "website_url": "http://smith-diaz.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 38, + "fields": { + "created": "2020-05-02T20:32:35.699Z", + "modified": "2020-05-02T20:32:35.699Z", + "name": "CollectionRule-28", + "url": "http://www.gonzalez-castillo.com/", + "website_url": "http://www.conley.biz/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 39, + "fields": { + "created": "2020-05-02T20:32:35.758Z", + "modified": "2020-05-02T20:32:35.758Z", + "name": "CollectionRule-29", + "url": "https://rogers-smith.net/", + "website_url": "http://www.sharp.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 40, + "fields": { + "created": "2020-05-02T20:32:35.814Z", + "modified": "2020-05-02T20:32:35.814Z", + "name": "CollectionRule-30", + "url": "https://neal-salinas.com/", + "website_url": "https://www.baird-warner.net/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 41, + "fields": { + "created": "2020-05-02T20:32:35.873Z", + "modified": "2020-05-02T20:32:35.874Z", + "name": "CollectionRule-31", + "url": "http://www.williams.com/", + "website_url": "http://www.wood.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 42, + "fields": { + "created": "2020-05-02T20:32:35.930Z", + "modified": "2020-05-02T20:32:35.930Z", + "name": "CollectionRule-32", + "url": "https://www.mueller.com/", + "website_url": "http://www.miller-ramirez.org/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 43, + "fields": { + "created": "2020-05-02T20:32:35.988Z", + "modified": "2020-05-02T20:32:35.989Z", + "name": "CollectionRule-33", + "url": "http://lee.com/", + "website_url": "http://www.moody.org/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 44, + "fields": { + "created": "2020-05-02T20:32:36.044Z", + "modified": "2020-05-02T20:32:36.045Z", + "name": "CollectionRule-34", + "url": "http://estrada.com/", + "website_url": "http://www.hicks.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 45, + "fields": { + "created": "2020-05-02T20:32:36.102Z", + "modified": "2020-05-02T20:32:36.102Z", + "name": "CollectionRule-35", + "url": "https://griffin-brewer.org/", + "website_url": "http://jones.info/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 46, + "fields": { + "created": "2020-05-02T20:32:36.161Z", + "modified": "2020-05-02T20:32:36.161Z", + "name": "CollectionRule-36", + "url": "http://www.dixon-johnson.com/", + "website_url": "https://mason.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 47, + "fields": { + "created": "2020-05-02T20:32:36.217Z", + "modified": "2020-05-02T20:32:36.217Z", + "name": "CollectionRule-37", + "url": "https://perez.com/", + "website_url": "http://www.miller.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 48, + "fields": { + "created": "2020-05-02T20:32:36.278Z", + "modified": "2020-05-02T20:32:36.279Z", + "name": "CollectionRule-38", + "url": "https://www.grant.net/", + "website_url": "https://www.clayton.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 49, + "fields": { + "created": "2020-05-02T20:32:36.336Z", + "modified": "2020-05-02T20:32:36.336Z", + "name": "CollectionRule-39", + "url": "http://www.lewis.org/", + "website_url": "http://cook.org/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 50, + "fields": { + "created": "2020-05-02T20:32:36.395Z", + "modified": "2020-05-02T20:32:36.395Z", + "name": "CollectionRule-40", + "url": "https://galloway-allen.net/", + "website_url": "http://www.rodriguez-callahan.info/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 51, + "fields": { + "created": "2020-05-02T20:32:36.453Z", + "modified": "2020-05-02T20:32:36.453Z", + "name": "CollectionRule-41", + "url": "https://www.macias.com/", + "website_url": "https://jarvis-green.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 52, + "fields": { + "created": "2020-05-02T20:32:36.510Z", + "modified": "2020-05-02T20:32:36.510Z", + "name": "CollectionRule-42", + "url": "http://mccullough-grant.com/", + "website_url": "https://shannon.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 53, + "fields": { + "created": "2020-05-02T20:32:36.566Z", + "modified": "2020-05-02T20:32:36.566Z", + "name": "CollectionRule-43", + "url": "http://www.foster-oneal.org/", + "website_url": "http://johns.org/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 54, + "fields": { + "created": "2020-05-02T20:32:36.623Z", + "modified": "2020-05-02T20:32:36.623Z", + "name": "CollectionRule-44", + "url": "http://www.wright.net/", + "website_url": "http://www.ali.biz/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 55, + "fields": { + "created": "2020-05-02T20:32:36.682Z", + "modified": "2020-05-02T20:32:36.682Z", + "name": "CollectionRule-45", + "url": "http://www.payne-gibbs.info/", + "website_url": "http://knight.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 56, + "fields": { + "created": "2020-05-02T20:32:36.740Z", + "modified": "2020-05-02T20:32:36.740Z", + "name": "CollectionRule-46", + "url": "http://hammond.biz/", + "website_url": "http://www.nelson.net/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 57, + "fields": { + "created": "2020-05-02T20:32:36.797Z", + "modified": "2020-05-02T20:32:36.797Z", + "name": "CollectionRule-47", + "url": "http://gilmore.com/", + "website_url": "http://coleman.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 58, + "fields": { + "created": "2020-05-02T20:32:36.855Z", + "modified": "2020-05-02T20:32:36.855Z", + "name": "CollectionRule-48", + "url": "https://www.hernandez.com/", + "website_url": "https://www.phillips.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 59, + "fields": { + "created": "2020-05-02T20:32:36.912Z", + "modified": "2020-05-02T20:32:36.912Z", + "name": "CollectionRule-49", + "url": "https://www.nguyen.com/", + "website_url": "http://www.floyd.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 60, + "fields": { + "created": "2020-05-02T20:32:36.969Z", + "modified": "2020-05-02T20:32:36.969Z", + "name": "CollectionRule-50", + "url": "https://meyer-brown.net/", + "website_url": "https://www.blankenship.biz/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 61, + "fields": { + "created": "2020-05-02T20:32:37.026Z", + "modified": "2020-05-02T20:32:37.027Z", + "name": "CollectionRule-51", + "url": "https://marks.net/", + "website_url": "http://gregory.net/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 62, + "fields": { + "created": "2020-05-02T20:32:37.087Z", + "modified": "2020-05-02T20:32:37.087Z", + "name": "CollectionRule-52", + "url": "http://www.baxter.com/", + "website_url": "http://barrera.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 63, + "fields": { + "created": "2020-05-02T20:32:37.143Z", + "modified": "2020-05-02T20:32:37.143Z", + "name": "CollectionRule-53", + "url": "http://johnson.com/", + "website_url": "https://abbott.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 64, + "fields": { + "created": "2020-05-02T20:32:37.202Z", + "modified": "2020-05-02T20:32:37.202Z", + "name": "CollectionRule-54", + "url": "https://hebert-marshall.biz/", + "website_url": "https://www.ashley-walsh.org/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 65, + "fields": { + "created": "2020-05-02T20:32:37.261Z", + "modified": "2020-05-02T20:32:37.261Z", + "name": "CollectionRule-55", + "url": "https://miller.com/", + "website_url": "https://www.hoffman.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 66, + "fields": { + "created": "2020-05-02T20:32:37.320Z", + "modified": "2020-05-02T20:32:37.320Z", + "name": "CollectionRule-56", + "url": "http://frey.com/", + "website_url": "https://long.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 67, + "fields": { + "created": "2020-05-02T20:32:37.379Z", + "modified": "2020-05-02T20:32:37.379Z", + "name": "CollectionRule-57", + "url": "https://edwards.com/", + "website_url": "http://www.nixon-doyle.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 68, + "fields": { + "created": "2020-05-02T20:32:37.435Z", + "modified": "2020-05-02T20:32:37.435Z", + "name": "CollectionRule-58", + "url": "https://www.bennett.com/", + "website_url": "http://sullivan.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 69, + "fields": { + "created": "2020-05-02T20:32:37.493Z", + "modified": "2020-05-02T20:32:37.493Z", + "name": "CollectionRule-59", + "url": "http://stokes-thomas.com/", + "website_url": "http://morgan.net/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 70, + "fields": { + "created": "2020-05-02T20:32:37.550Z", + "modified": "2020-05-02T20:32:37.550Z", + "name": "CollectionRule-60", + "url": "https://moore.net/", + "website_url": "http://www.hubbard.biz/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 71, + "fields": { + "created": "2020-05-02T20:32:37.609Z", + "modified": "2020-05-02T20:32:37.609Z", + "name": "CollectionRule-61", + "url": "https://baker-edwards.com/", + "website_url": "https://www.anderson.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 72, + "fields": { + "created": "2020-05-02T20:32:37.666Z", + "modified": "2020-05-02T20:32:37.666Z", + "name": "CollectionRule-62", + "url": "https://www.jackson.com/", + "website_url": "https://www.edwards.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 73, + "fields": { + "created": "2020-05-02T20:32:37.724Z", + "modified": "2020-05-02T20:32:37.724Z", + "name": "CollectionRule-63", + "url": "https://kemp-pollard.biz/", + "website_url": "http://www.fuentes.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 74, + "fields": { + "created": "2020-05-02T20:32:37.782Z", + "modified": "2020-05-02T20:32:37.782Z", + "name": "CollectionRule-64", + "url": "https://hanna-cook.com/", + "website_url": "http://www.bowen.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 75, + "fields": { + "created": "2020-05-02T20:32:37.839Z", + "modified": "2020-05-02T20:32:37.839Z", + "name": "CollectionRule-65", + "url": "http://www.williams.net/", + "website_url": "http://www.chandler.org/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 76, + "fields": { + "created": "2020-05-02T20:32:37.896Z", + "modified": "2020-05-02T20:32:37.896Z", + "name": "CollectionRule-66", + "url": "https://www.alexander.com/", + "website_url": "https://johnson-ellis.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 77, + "fields": { + "created": "2020-05-02T20:32:37.951Z", + "modified": "2020-05-02T20:32:37.951Z", + "name": "CollectionRule-67", + "url": "https://www.cisneros.com/", + "website_url": "http://fox.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 78, + "fields": { + "created": "2020-05-02T20:32:38.008Z", + "modified": "2020-05-02T20:32:38.008Z", + "name": "CollectionRule-68", + "url": "http://www.foster-burton.com/", + "website_url": "https://grant.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 79, + "fields": { + "created": "2020-05-02T20:32:38.066Z", + "modified": "2020-05-02T20:32:38.066Z", + "name": "CollectionRule-69", + "url": "https://www.hayes.net/", + "website_url": "http://morgan.com/", + "favicon": null, + "timezone": "UTC", + "category": null, + "last_suceeded": null, + "succeeded": false, + "error": null, + "user": [ + "sonny@bakker.nl" + ] + } } ] diff --git a/src/newsreader/js/components/Selector.js b/src/newsreader/js/components/Selector.js new file mode 100644 index 0000000..8b701f5 --- /dev/null +++ b/src/newsreader/js/components/Selector.js @@ -0,0 +1,23 @@ +class Selector { + onClick = ::this.onClick; + + inputs = []; + + constructor() { + const selectAllInput = document.querySelector('#select-all'); + + this.inputs = document.querySelectorAll(`[name=${selectAllInput.dataset.input}`); + + selectAllInput.onchange = this.onClick; + } + + onClick(e) { + const targetValue = e.target.checked; + + this.inputs.forEach(input => { + input.checked = targetValue; + }); + } +} + +export default Selector; diff --git a/src/newsreader/js/index.js b/src/newsreader/js/index.js index 48db0b2..1ed14ed 100644 --- a/src/newsreader/js/index.js +++ b/src/newsreader/js/index.js @@ -1,3 +1,3 @@ import './pages/homepage/index.js'; -import './pages/rules/index.js'; import './pages/categories/index.js'; +import './pages/rules/index.js'; diff --git a/src/newsreader/js/pages/rules/App.js b/src/newsreader/js/pages/rules/App.js deleted file mode 100644 index 7ceae4a..0000000 --- a/src/newsreader/js/pages/rules/App.js +++ /dev/null @@ -1,106 +0,0 @@ -import React from 'react'; - -import Cookies from 'js-cookie'; - -import Card from '../../components/Card.js'; -import RuleCard from './components/RuleCard.js'; -import RuleModal from './components/RuleModal.js'; -import Messages from '../../components/Messages.js'; - -class App extends React.Component { - selectRule = ::this.selectRule; - deselectRule = ::this.deselectRule; - deleteRule = ::this.deleteRule; - - constructor(props) { - super(props); - - this.token = Cookies.get('csrftoken'); - this.state = { - rules: props.rules, - selectedRuleId: null, - message: null, - }; - } - - selectRule(ruleId) { - this.setState({ selectedRuleId: ruleId }); - } - - deselectRule() { - this.setState({ selectedRuleId: null }); - } - - deleteRule(ruleId) { - const url = `/api/rules/${ruleId}/`; - const options = { - method: 'DELETE', - headers: { - 'X-CSRFToken': this.token, - }, - }; - - fetch(url, options).then(response => { - if (response.ok) { - const rules = this.state.rules.filter(rule => { - return rule.pk != ruleId; - }); - - return this.setState({ - rules: rules, - selectedRuleId: null, - message: null, - }); - } - }); - - const message = { - type: 'error', - text: 'Unable to remove rule, try again later', - }; - return this.setState({ selectedRuleId: null, message: message }); - } - - render() { - const { rules } = this.state; - const cards = rules.map(rule => { - return ; - }); - - const selectedRule = rules.find(rule => { - return rule.pk === this.state.selectedRuleId; - }); - - const pageHeader = ( - <> -

      Rules

      - - - - ); - - return ( - <> - {this.state.message && } - - {cards} - {selectedRule && ( - - )} - - ); - } -} - -export default App; diff --git a/src/newsreader/js/pages/rules/components/RuleCard.js b/src/newsreader/js/pages/rules/components/RuleCard.js deleted file mode 100644 index d74b8d1..0000000 --- a/src/newsreader/js/pages/rules/components/RuleCard.js +++ /dev/null @@ -1,65 +0,0 @@ -import React from 'react'; - -import Card from '../../../components/Card.js'; - -const RuleCard = props => { - const { rule } = props; - let favicon = null; - - if (rule.favicon) { - favicon = ; - } else { - favicon = ; - } - - const stateIcon = !rule.error ? 'gg-check' : 'gg-danger'; - - const cardHeader = ( - <> - -

      {rule.name}

      - {favicon} - - ); - - const cardContent = ( - <> -
        - {rule.error && ( -
          -
        • {rule.error}
        • -
        - )} - - {rule.category &&
      • {rule.category}
      • } -
      • - - {rule.url} - -
      • -
      • {rule.created}
      • -
      • {rule.timezone}
      • -
      - - ); - - const cardFooter = ( - <> - - Edit - - - - ); - - return ; -}; - -export default RuleCard; diff --git a/src/newsreader/js/pages/rules/components/RuleModal.js b/src/newsreader/js/pages/rules/components/RuleModal.js deleted file mode 100644 index d174cc3..0000000 --- a/src/newsreader/js/pages/rules/components/RuleModal.js +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; - -import Modal from '../../../components/Modal.js'; - -const RuleModal = props => { - const content = ( - <> -
      -
      -

      Delete rule

      -
      - -
      -

      Are you sure you want to delete {props.rule.name}?

      -
      - -
      - - -
      -
      - - ); - - return ; -}; - -export default RuleModal; diff --git a/src/newsreader/js/pages/rules/index.js b/src/newsreader/js/pages/rules/index.js index d0b46e9..b888121 100644 --- a/src/newsreader/js/pages/rules/index.js +++ b/src/newsreader/js/pages/rules/index.js @@ -1,13 +1,7 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; - -import App from './App.js'; +import Selector from '../../components/Selector.js'; const page = document.getElementById('rules--page'); if (page) { - const dataScript = document.getElementById('rules-data'); - const rules = JSON.parse(dataScript.textContent); - - ReactDOM.render(, page); + new Selector(); } diff --git a/src/newsreader/news/collection/forms.py b/src/newsreader/news/collection/forms.py index d0b02be..bfa0d90 100644 --- a/src/newsreader/news/collection/forms.py +++ b/src/newsreader/news/collection/forms.py @@ -36,6 +36,17 @@ class CollectionRuleForm(forms.ModelForm): fields = ("name", "url", "timezone", "favicon", "category") +class CollectionRuleBulkForm(forms.Form): + rules = forms.ModelMultipleChoiceField(queryset=CollectionRule.objects.none()) + + def __init__(self, user, *args, **kwargs): + self.user = user + + super().__init__(*args, **kwargs) + + self.fields["rules"].queryset = CollectionRule.objects.filter(user=user) + + class OPMLImportForm(forms.Form): file = forms.FileField(allow_empty_file=False) skip_existing = forms.BooleanField(initial=False, required=False) diff --git a/src/newsreader/news/collection/migrations/0007_collectionrule_enabled.py b/src/newsreader/news/collection/migrations/0007_collectionrule_enabled.py new file mode 100644 index 0000000..fe6b0eb --- /dev/null +++ b/src/newsreader/news/collection/migrations/0007_collectionrule_enabled.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.5 on 2020-05-10 13:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("collection", "0006_auto_20200412_1955")] + + operations = [ + migrations.AddField( + model_name="collectionrule", + name="enabled", + field=models.BooleanField( + default=True, help_text="Wether or not to collect items from this feed" + ), + ) + ] diff --git a/src/newsreader/news/collection/models.py b/src/newsreader/news/collection/models.py index d1d62ce..a5bfdfb 100644 --- a/src/newsreader/news/collection/models.py +++ b/src/newsreader/news/collection/models.py @@ -10,9 +10,7 @@ class CollectionRule(TimeStampedModel): name = models.CharField(max_length=100) url = models.URLField(max_length=1024) - website_url = models.URLField( - max_length=1024, editable=False, blank=True, null=True - ) + website_url = models.URLField(max_length=1024, editable=False, blank=True, null=True) favicon = models.URLField(blank=True, null=True) timezone = models.CharField( @@ -34,6 +32,9 @@ class CollectionRule(TimeStampedModel): last_suceeded = models.DateTimeField(blank=True, null=True) succeeded = models.BooleanField(default=False) error = models.CharField(max_length=1024, blank=True, null=True) + enabled = models.BooleanField( + default=True, help_text=_("Wether or not to collect items from this feed") + ) user = models.ForeignKey( "accounts.User", diff --git a/src/newsreader/news/collection/templates/collection/rules.html b/src/newsreader/news/collection/templates/collection/rules.html index 508916a..23b3fe7 100644 --- a/src/newsreader/news/collection/templates/collection/rules.html +++ b/src/newsreader/news/collection/templates/collection/rules.html @@ -1,30 +1,71 @@ {% extends "base.html" %} +{% load i18n %} {% load static %} {% block content %} -
      -{% endblock %} +
      + + {% csrf_token %} -{% block scripts %} - +
      + + + +
      + + + + + + + + + + + + + + {% for rule in rules %} + + + + + + + + + + {% endfor %} + +
      + + {% trans "Name" %}{% trans "Category" %}{% trans "URL" %}{% trans "Successfuly ran" %}{% trans "Enabled" %}
      {{ rule.name }}{{ rule.category.name }}{{ rule.url }}{{ rule.succeeded }}{{ rule.enabled }} + +
      + + +
      {% endblock %} diff --git a/src/newsreader/news/collection/tests/views/test_bulk_views.py b/src/newsreader/news/collection/tests/views/test_bulk_views.py new file mode 100644 index 0000000..7679907 --- /dev/null +++ b/src/newsreader/news/collection/tests/views/test_bulk_views.py @@ -0,0 +1,244 @@ + +from django.conf import settings +from django.test import TestCase +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +import pytz + +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.core.tests.factories import CategoryFactory + + +class CollectionRuleBulkViewTestCase: + def setUp(self): + self.redirect_url = reverse("rules") + + self.user = UserFactory() + self.client.force_login(self.user) + + +class CollectionRuleBulkEnableViewTestCase(CollectionRuleBulkViewTestCase, TestCase): + def setUp(self): + super().setUp() + + self.url = reverse("rules-enable") + + self.rules = CollectionRuleFactory.create_batch( + size=5, user=self.user, enabled=False + ) + + def test_simple(self): + response = self.client.post( + self.url, {"rules": [rule.pk for rule in self.rules]}, follow=True + ) + + self.assertRedirects(response, self.redirect_url) + + rules = CollectionRule.objects.filter(user=self.user) + + for rule in rules: + with self.subTest(rule=rule): + self.assertEqual(rule.enabled, True) + + self.assertNotContains(response, _("The form contains errors, try again later")) + + def test_empty_rules(self): + response = self.client.post(self.url, {"rules": []}, follow=True) + + self.assertRedirects(response, self.redirect_url) + + rules = CollectionRule.objects.filter(user=self.user) + + for rule in rules: + with self.subTest(rule=rule): + self.assertEqual(rule.enabled, False) + + self.assertContains(response, _("The form contains errors, try again later")) + + def test_rule_from_other_user(self): + other_user = UserFactory() + other_rules = CollectionRuleFactory.create_batch( + size=5, user=other_user, enabled=False + ) + + response = self.client.post( + self.url, + {"rules": [other_rule.pk for other_rule in other_rules]}, + follow=True, + ) + + self.assertRedirects(response, self.redirect_url) + + rules = CollectionRule.objects.filter(user=other_user) + + for rule in rules: + with self.subTest(rule=rule): + self.assertEqual(rule.enabled, False) + + self.assertContains(response, _("The form contains errors, try again later")) + + def test_unauthenticated(self): + self.client.logout() + + response = self.client.post( + self.url, {"rules": [rule.pk for rule in self.rules]}, follow=True + ) + + self.assertRedirects( + response, f"{reverse('accounts:login')}?next={reverse('rules-enable')}" + ) + + rules = CollectionRule.objects.filter(user=self.user) + + for rule in rules: + with self.subTest(rule=rule): + self.assertEqual(rule.enabled, False) + + +class CollectionRuleBulkDisableViewTestCase(CollectionRuleBulkViewTestCase, TestCase): + def setUp(self): + super().setUp() + + self.url = reverse("rules-disable") + + self.rules = CollectionRuleFactory.create_batch( + size=5, user=self.user, enabled=True + ) + + def test_simple(self): + response = self.client.post( + self.url, {"rules": [rule.pk for rule in self.rules]}, follow=True + ) + + self.assertRedirects(response, self.redirect_url) + + rules = CollectionRule.objects.filter(user=self.user) + + for rule in rules: + with self.subTest(rule=rule): + self.assertEqual(rule.enabled, False) + + self.assertNotContains(response, _("The form contains errors, try again later")) + + def test_empty_rules(self): + response = self.client.post(self.url, {"rules": []}, follow=True) + + self.assertRedirects(response, self.redirect_url) + + rules = CollectionRule.objects.filter(user=self.user) + + for rule in rules: + with self.subTest(rule=rule): + self.assertEqual(rule.enabled, True) + + self.assertContains(response, _("The form contains errors, try again later")) + + def test_rule_from_other_user(self): + other_user = UserFactory() + other_rules = CollectionRuleFactory.create_batch( + size=5, user=other_user, enabled=True + ) + + response = self.client.post( + self.url, + {"rules": [other_rule.pk for other_rule in other_rules]}, + follow=True, + ) + + self.assertRedirects(response, self.redirect_url) + + rules = CollectionRule.objects.filter(user=other_user) + + for rule in rules: + with self.subTest(rule=rule): + self.assertEqual(rule.enabled, True) + + self.assertContains(response, _("The form contains errors, try again later")) + + def test_unauthenticated(self): + self.client.logout() + + response = self.client.post( + self.url, {"rules": [rule.pk for rule in self.rules]}, follow=True + ) + + self.assertRedirects( + response, f"{reverse('accounts:login')}?next={reverse('rules-disable')}" + ) + + rules = CollectionRule.objects.filter(user=self.user) + + for rule in rules: + with self.subTest(rule=rule): + self.assertEqual(rule.enabled, True) + + +class CollectionRuleBulkDeleteViewTestCase(CollectionRuleBulkViewTestCase, TestCase): + def setUp(self): + super().setUp() + + self.url = reverse("rules-delete") + + self.rules = CollectionRuleFactory.create_batch(size=5, user=self.user) + + def test_simple(self): + response = self.client.post( + self.url, {"rules": [rule.pk for rule in self.rules]}, follow=True + ) + + self.assertRedirects(response, self.redirect_url) + + rules = CollectionRule.objects.filter(user=self.user) + + self.assertCountEqual(rules, []) + + self.assertNotContains(response, _("The form contains errors, try again later")) + + def test_empty_rules(self): + response = self.client.post(self.url, {"rules": []}, follow=True) + + self.assertRedirects(response, self.redirect_url) + + rules = CollectionRule.objects.filter(user=self.user) + + self.assertCountEqual(rules, self.rules) + + self.assertContains(response, _("The form contains errors, try again later")) + + def test_rule_from_other_user(self): + other_user = UserFactory() + other_rules = CollectionRuleFactory.create_batch( + size=5, user=other_user, enabled=True + ) + + response = self.client.post( + self.url, + {"rules": [other_rule.pk for other_rule in other_rules]}, + follow=True, + ) + + self.assertRedirects(response, self.redirect_url) + + rules = CollectionRule.objects.filter(user=other_user) + + self.assertCountEqual(rules, other_rules) + + self.assertContains(response, _("The form contains errors, try again later")) + + def test_unauthenticated(self): + self.client.logout() + + response = self.client.post( + self.url, {"rules": [rule.pk for rule in self.rules]}, follow=True + ) + + self.assertRedirects( + response, f"{reverse('accounts:login')}?next={reverse('rules-delete')}" + ) + + rules = CollectionRule.objects.filter(user=self.user) + + self.assertCountEqual(rules, self.rules) diff --git a/src/newsreader/news/collection/tests/test_views.py b/src/newsreader/news/collection/tests/views/test_crud.py similarity index 52% rename from src/newsreader/news/collection/tests/test_views.py rename to src/newsreader/news/collection/tests/views/test_crud.py index 0acd3ed..d77bcf6 100644 --- a/src/newsreader/news/collection/tests/test_views.py +++ b/src/newsreader/news/collection/tests/views/test_crud.py @@ -150,135 +150,3 @@ class CollectionRuleUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): self.rule.refresh_from_db() self.assertEquals(self.rule.category, None) - - -class OPMLImportTestCase(TestCase): - def setUp(self): - self.user = UserFactory(password="test") - self.client.force_login(self.user) - - self.form_data = {"file": "", "skip_existing": False} - self.url = reverse("import") - - def _get_file_path(self, name): - file_dir = os.path.join(settings.DJANGO_PROJECT_DIR, "utils", "tests", "files") - return os.path.join(file_dir, name) - - def test_simple(self): - file_path = self._get_file_path("feeds.opml") - - with open(file_path) as file: - self.form_data.update(file=file) - - response = self.client.post(self.url, self.form_data) - - self.assertRedirects(response, reverse("rules")) - - rules = CollectionRule.objects.all() - self.assertEquals(len(rules), 4) - - def test_existing_rules(self): - CollectionRuleFactory( - url="http://www.engadget.com/rss-full.xml", user=self.user - ) - CollectionRuleFactory(url="https://news.ycombinator.com/rss", user=self.user) - CollectionRuleFactory( - url="http://feeds.feedburner.com/Techcrunch", user=self.user - ) - CollectionRuleFactory( - url="http://feeds.feedburner.com/tweakers/nieuws", user=self.user - ) - - file_path = self._get_file_path("feeds.opml") - - with open(file_path) as file: - self.form_data.update(file=file) - - response = self.client.post(self.url, self.form_data) - - self.assertRedirects(response, reverse("rules")) - - rules = CollectionRule.objects.all() - self.assertEquals(len(rules), 8) - - def test_skip_existing_rules(self): - CollectionRuleFactory( - url="http://www.engadget.com/rss-full.xml", user=self.user - ) - CollectionRuleFactory(url="https://news.ycombinator.com/rss", user=self.user) - CollectionRuleFactory( - url="http://feeds.feedburner.com/Techcrunch", user=self.user - ) - CollectionRuleFactory( - url="http://feeds.feedburner.com/tweakers/nieuws", user=self.user - ) - - file_path = self._get_file_path("feeds.opml") - - with open(file_path) as file: - self.form_data.update(file=file, skip_existing=True) - - response = self.client.post(self.url, self.form_data) - - self.assertEquals(response.status_code, 200) - - rules = CollectionRule.objects.all() - self.assertEquals(len(rules), 4) - - def test_empty_feed_file(self): - file_path = self._get_file_path("empty-feeds.opml") - - with open(file_path) as file: - self.form_data.update(file=file) - - response = self.client.post(self.url, self.form_data) - - self.assertEquals(response.status_code, 200) - - rules = CollectionRule.objects.all() - self.assertEquals(len(rules), 0) - - self.assertFormError(response, "form", "file", _("No (new) rules found")) - - def test_invalid_feeds(self): - file_path = self._get_file_path("invalid-url-feeds.opml") - - with open(file_path) as file: - self.form_data.update(file=file) - - response = self.client.post(self.url, self.form_data) - - self.assertEquals(response.status_code, 200) - - rules = CollectionRule.objects.all() - - self.assertEquals(len(rules), 0) - self.assertFormError(response, "form", "file", _("No (new) rules found")) - - def test_invalid_file(self): - file_path = self._get_file_path("test.png") - - with open(file_path, "rb") as file: - self.form_data.update(file=file) - - response = self.client.post(self.url, self.form_data) - - self.assertEquals(response.status_code, 200) - - rules = CollectionRule.objects.all() - self.assertEquals(len(rules), 0) - - self.assertFormError(response, "form", "file", _("Invalid OPML file")) - - def test_feeds_with_missing_attr(self): - file_path = self._get_file_path("missing-feeds.opml") - - with open(file_path) as file: - self.form_data.update(file=file) - - response = self.client.post(self.url, self.form_data) - - self.assertRedirects(response, reverse("rules")) - - rules = CollectionRule.objects.all() - self.assertEquals(len(rules), 2) diff --git a/src/newsreader/news/collection/tests/views/test_import_view.py b/src/newsreader/news/collection/tests/views/test_import_view.py new file mode 100644 index 0000000..57ac502 --- /dev/null +++ b/src/newsreader/news/collection/tests/views/test_import_view.py @@ -0,0 +1,141 @@ +import os + +from django.conf import settings +from django.test import TestCase +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +import pytz + +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.core.tests.factories import CategoryFactory + + +class OPMLImportTestCase(TestCase): + def setUp(self): + self.user = UserFactory(password="test") + self.client.force_login(self.user) + + self.form_data = {"file": "", "skip_existing": False} + self.url = reverse("import") + + def _get_file_path(self, name): + file_dir = os.path.join(settings.DJANGO_PROJECT_DIR, "utils", "tests", "files") + return os.path.join(file_dir, name) + + def test_simple(self): + file_path = self._get_file_path("feeds.opml") + + with open(file_path) as file: + self.form_data.update(file=file) + + response = self.client.post(self.url, self.form_data) + + self.assertRedirects(response, reverse("rules")) + + rules = CollectionRule.objects.all() + self.assertEquals(len(rules), 4) + + def test_existing_rules(self): + CollectionRuleFactory(url="http://www.engadget.com/rss-full.xml", user=self.user) + CollectionRuleFactory(url="https://news.ycombinator.com/rss", user=self.user) + CollectionRuleFactory( + url="http://feeds.feedburner.com/Techcrunch", user=self.user + ) + CollectionRuleFactory( + url="http://feeds.feedburner.com/tweakers/nieuws", user=self.user + ) + + file_path = self._get_file_path("feeds.opml") + + with open(file_path) as file: + self.form_data.update(file=file) + + response = self.client.post(self.url, self.form_data) + + self.assertRedirects(response, reverse("rules")) + + rules = CollectionRule.objects.all() + self.assertEquals(len(rules), 8) + + def test_skip_existing_rules(self): + CollectionRuleFactory(url="http://www.engadget.com/rss-full.xml", user=self.user) + CollectionRuleFactory(url="https://news.ycombinator.com/rss", user=self.user) + CollectionRuleFactory( + url="http://feeds.feedburner.com/Techcrunch", user=self.user + ) + CollectionRuleFactory( + url="http://feeds.feedburner.com/tweakers/nieuws", user=self.user + ) + + file_path = self._get_file_path("feeds.opml") + + with open(file_path) as file: + self.form_data.update(file=file, skip_existing=True) + + response = self.client.post(self.url, self.form_data) + + self.assertEquals(response.status_code, 200) + + rules = CollectionRule.objects.all() + self.assertEquals(len(rules), 4) + + def test_empty_feed_file(self): + file_path = self._get_file_path("empty-feeds.opml") + + with open(file_path) as file: + self.form_data.update(file=file) + + response = self.client.post(self.url, self.form_data) + + self.assertEquals(response.status_code, 200) + + rules = CollectionRule.objects.all() + self.assertEquals(len(rules), 0) + + self.assertFormError(response, "form", "file", _("No (new) rules found")) + + def test_invalid_feeds(self): + file_path = self._get_file_path("invalid-url-feeds.opml") + + with open(file_path) as file: + self.form_data.update(file=file) + + response = self.client.post(self.url, self.form_data) + + self.assertEquals(response.status_code, 200) + + rules = CollectionRule.objects.all() + + self.assertEquals(len(rules), 0) + self.assertFormError(response, "form", "file", _("No (new) rules found")) + + def test_invalid_file(self): + file_path = self._get_file_path("test.png") + + with open(file_path, "rb") as file: + self.form_data.update(file=file) + + response = self.client.post(self.url, self.form_data) + + self.assertEquals(response.status_code, 200) + + rules = CollectionRule.objects.all() + self.assertEquals(len(rules), 0) + + self.assertFormError(response, "form", "file", _("Invalid OPML file")) + + def test_feeds_with_missing_attr(self): + file_path = self._get_file_path("missing-feeds.opml") + + with open(file_path) as file: + self.form_data.update(file=file) + + response = self.client.post(self.url, self.form_data) + + self.assertRedirects(response, reverse("rules")) + + rules = CollectionRule.objects.all() + self.assertEquals(len(rules), 2) diff --git a/src/newsreader/news/collection/urls.py b/src/newsreader/news/collection/urls.py index 28b6f38..1ea17d6 100644 --- a/src/newsreader/news/collection/urls.py +++ b/src/newsreader/news/collection/urls.py @@ -8,6 +8,9 @@ from newsreader.news.collection.endpoints import ( RuleReadView, ) from newsreader.news.collection.views import ( + CollectionRuleBulkDeleteView, + CollectionRuleBulkDisableView, + CollectionRuleBulkEnableView, CollectionRuleCreateView, CollectionRuleListView, CollectionRuleUpdateView, @@ -34,5 +37,20 @@ urlpatterns = [ login_required(CollectionRuleCreateView.as_view()), name="rule-create", ), + path( + "rules/delete/", + login_required(CollectionRuleBulkDeleteView.as_view()), + name="rules-delete", + ), + path( + "rules/enable/", + login_required(CollectionRuleBulkEnableView.as_view()), + name="rules-enable", + ), + path( + "rules/disable/", + login_required(CollectionRuleBulkDisableView.as_view()), + name="rules-disable", + ), path("rules/import/", login_required(OPMLImportView.as_view()), name="import"), ] diff --git a/src/newsreader/news/collection/views.py b/src/newsreader/news/collection/views.py index 8d254e2..ca531fb 100644 --- a/src/newsreader/news/collection/views.py +++ b/src/newsreader/news/collection/views.py @@ -1,12 +1,17 @@ from django.contrib import messages -from django.urls import reverse_lazy +from django.shortcuts import redirect +from django.urls import reverse, reverse_lazy from django.utils.translation import gettext_lazy as _ from django.views.generic.edit import CreateView, FormView, UpdateView from django.views.generic.list import ListView import pytz -from newsreader.news.collection.forms import CollectionRuleForm, OPMLImportForm +from newsreader.news.collection.forms import ( + CollectionRuleBulkForm, + CollectionRuleForm, + OPMLImportForm, +) from newsreader.news.collection.models import CollectionRule from newsreader.news.core.models import Category from newsreader.utils.opml import parse_opml @@ -17,7 +22,7 @@ class CollectionRuleViewMixin: def get_queryset(self): user = self.request.user - return self.queryset.filter(user=user) + return self.queryset.filter(user=user).order_by("name") class CollectionRuleDetailMixin: @@ -42,6 +47,7 @@ class CollectionRuleDetailMixin: class CollectionRuleListView(CollectionRuleViewMixin, ListView): + paginate_by = 50 template_name = "collection/rules.html" context_object_name = "rules" @@ -59,6 +65,60 @@ class CollectionRuleCreateView( template_name = "collection/rule-create.html" +class CollectionRuleBulkView(FormView): + form_class = CollectionRuleBulkForm + + def get_redirect_url(self): + return reverse("rules") + + def get_success_url(self): + return self.get_redirect_url() + + def get_form(self, form_class=None): + if form_class is None: + form_class = self.get_form_class() + return form_class(self.request.user, **self.get_form_kwargs()) + + def form_invalid(self, form): + url = self.get_redirect_url() + + messages.error(self.request, _("The form contains errors, try again later")) + + return redirect(url) + + +class CollectionRuleBulkEnableView(CollectionRuleBulkView): + def form_valid(self, form): + response = super().form_valid(form) + + for rule in form.cleaned_data["rules"]: + rule.enabled = True + rule.save() + + return response + + +class CollectionRuleBulkDisableView(CollectionRuleBulkView): + def form_valid(self, form): + response = super().form_valid(form) + + for rule in form.cleaned_data["rules"]: + rule.enabled = False + rule.save() + + return response + + +class CollectionRuleBulkDeleteView(CollectionRuleBulkView): + def form_valid(self, form): + response = super().form_valid(form) + + for rule in form.cleaned_data["rules"]: + rule.delete() + + return response + + class OPMLImportView(FormView): form_class = OPMLImportForm success_url = reverse_lazy("rules") diff --git a/src/newsreader/scss/components/form/_form.scss b/src/newsreader/scss/components/form/_form.scss index 931fba9..5b97958 100644 --- a/src/newsreader/scss/components/form/_form.scss +++ b/src/newsreader/scss/components/form/_form.scss @@ -19,6 +19,14 @@ padding: 15px; } + &__actions { + display: flex; + justify-content: space-between; + + width: 50%; + padding: 15px; + } + &__title { font-size: 18px; } diff --git a/src/newsreader/scss/components/form/_rules-form.scss b/src/newsreader/scss/components/form/_rules-form.scss new file mode 100644 index 0000000..44d4765 --- /dev/null +++ b/src/newsreader/scss/components/form/_rules-form.scss @@ -0,0 +1,5 @@ +.rules-form { + @extend .form; + + width: 90%; +} diff --git a/src/newsreader/scss/components/form/index.scss b/src/newsreader/scss/components/form/index.scss index 2c70cdd..547da89 100644 --- a/src/newsreader/scss/components/form/index.scss +++ b/src/newsreader/scss/components/form/index.scss @@ -2,6 +2,7 @@ @import "category-form"; @import "rule-form"; +@import "rules-form"; @import "import-form"; @import "login-form"; diff --git a/src/newsreader/scss/components/index.scss b/src/newsreader/scss/components/index.scss index 4bddb31..53e0f71 100644 --- a/src/newsreader/scss/components/index.scss +++ b/src/newsreader/scss/components/index.scss @@ -12,7 +12,9 @@ @import "section/index"; @import "errorlist/index"; @import "fieldset/index"; +@import "pagination/index"; @import "sidebar/index"; +@import "table/index"; @import "rules/index"; @import "category/index"; diff --git a/src/newsreader/scss/components/pagination/_pagination.scss b/src/newsreader/scss/components/pagination/_pagination.scss new file mode 100644 index 0000000..d4ba4a9 --- /dev/null +++ b/src/newsreader/scss/components/pagination/_pagination.scss @@ -0,0 +1,18 @@ +@import "../../elements/button/mixins"; + +.pagination { + display: flex; + justify-content: space-evenly; + + &__previous, &__current, &__next { + display: flex; + justify-content: space-evenly; + + width: 33%; + text-align: center; + } + + &__current { + @include button-padding; + } +} diff --git a/src/newsreader/scss/components/pagination/index.scss b/src/newsreader/scss/components/pagination/index.scss new file mode 100644 index 0000000..d92e61f --- /dev/null +++ b/src/newsreader/scss/components/pagination/index.scss @@ -0,0 +1 @@ +@import "pagination"; diff --git a/src/newsreader/scss/components/table/_rules-table.scss b/src/newsreader/scss/components/table/_rules-table.scss new file mode 100644 index 0000000..3eaf3b3 --- /dev/null +++ b/src/newsreader/scss/components/table/_rules-table.scss @@ -0,0 +1,38 @@ +.rules-table { + &__heading { + &--select { + width: 5%; + } + + &--name { + width: 20%; + } + + &--category { + width: 15%; + } + + &--url { + width: 40%; + } + + &--succeeded { + width: 15%; + } + + &--enabled { + width: 10%; + } + + &--link { + width: 5%; + } + } + + & .link { + display: flex; + justify-content: center; + + padding: 10px; + } +} diff --git a/src/newsreader/scss/components/table/_table.scss b/src/newsreader/scss/components/table/_table.scss new file mode 100644 index 0000000..60ab7e8 --- /dev/null +++ b/src/newsreader/scss/components/table/_table.scss @@ -0,0 +1,32 @@ +@import "../../lib/mixins"; + +.table { + @include rounded; + + table-layout: fixed; + background-color: $white; + width: 90%; + padding: 20px; + + text-align: left; + white-space: nowrap; + + &__heading { + @extend .h1; + } + + &__item { + padding: 10px 0; + + border-bottom: 1px solid $border-gray; + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__footer { + width: 80%; + padding: 10px 0; + } +} diff --git a/src/newsreader/scss/components/table/index.scss b/src/newsreader/scss/components/table/index.scss new file mode 100644 index 0000000..d175a21 --- /dev/null +++ b/src/newsreader/scss/components/table/index.scss @@ -0,0 +1,2 @@ +@import "table"; +@import "rules-table"; diff --git a/src/newsreader/scss/elements/button/_button.scss b/src/newsreader/scss/elements/button/_button.scss index a6bec19..3a06cd3 100644 --- a/src/newsreader/scss/elements/button/_button.scss +++ b/src/newsreader/scss/elements/button/_button.scss @@ -1,10 +1,12 @@ +@import "mixins"; + .button { display: flex; align-items: center; justify-content: center; - padding: 10px 50px; + @include button-padding; border: none; border-radius: 2px; diff --git a/src/newsreader/scss/elements/button/_mixins.scss b/src/newsreader/scss/elements/button/_mixins.scss new file mode 100644 index 0000000..06a912c --- /dev/null +++ b/src/newsreader/scss/elements/button/_mixins.scss @@ -0,0 +1,3 @@ +@mixin button-padding { + padding: 10px 50px; +} diff --git a/src/newsreader/scss/elements/link/_link.scss b/src/newsreader/scss/elements/link/_link.scss index 1843c0b..b485cb3 100644 --- a/src/newsreader/scss/elements/link/_link.scss +++ b/src/newsreader/scss/elements/link/_link.scss @@ -10,7 +10,3 @@ a { @extend .link; } - -.gg-link { - color: initial; -} diff --git a/src/newsreader/scss/lib/_css.gg.scss b/src/newsreader/scss/lib/_css.gg.scss index 389e533..e7096d5 100644 --- a/src/newsreader/scss/lib/_css.gg.scss +++ b/src/newsreader/scss/lib/_css.gg.scss @@ -1 +1,9 @@ @import "~css.gg/icons-scss/icons"; + +.gg-link { + color: initial; +} + +.gg-pen { + color: initial; +} diff --git a/src/newsreader/scss/lib/_mixins.scss b/src/newsreader/scss/lib/_mixins.scss new file mode 100644 index 0000000..e2d28aa --- /dev/null +++ b/src/newsreader/scss/lib/_mixins.scss @@ -0,0 +1,3 @@ +@mixin rounded { + border-radius: 5px; +} diff --git a/src/newsreader/scss/pages/index.scss b/src/newsreader/scss/pages/index.scss index 872ac89..27f0bc6 100644 --- a/src/newsreader/scss/pages/index.scss +++ b/src/newsreader/scss/pages/index.scss @@ -8,5 +8,5 @@ @import "password-reset/index"; @import "register/index"; -@import "rules/index"; @import "rule/index"; +@import "rules/index"; diff --git a/src/newsreader/scss/pages/rules/index.scss b/src/newsreader/scss/pages/rules/index.scss index 68b92cb..64f46b4 100644 --- a/src/newsreader/scss/pages/rules/index.scss +++ b/src/newsreader/scss/pages/rules/index.scss @@ -1,7 +1,5 @@ #rules--page { - .list__item { - & .link { - margin: 0; - } + & .table { + width: 100%; } } From 7ee727e96e1335f5406a2dae8a72a8ace57cb5c5 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 10 May 2020 21:52:54 +0200 Subject: [PATCH 085/422] Move nested collection & core app urls inside news app --- src/newsreader/conf/base.py | 1 + src/newsreader/news/apps.py | 5 ++ src/newsreader/news/collection/models.py | 4 +- .../templates/collection/import.html | 2 +- .../collection/templates/collection/rule.html | 2 +- .../templates/collection/rules.html | 8 +- .../tests/endpoints/rule/detail/tests.py | 62 +++++++++----- .../tests/endpoints/rule/list/tests.py | 55 ++++++------ .../collection/tests/views/test_bulk_views.py | 21 +++-- .../tests/views/test_import_view.py | 11 +-- src/newsreader/news/collection/views.py | 6 +- .../news/core/templates/core/category.html | 2 +- .../tests/endpoints/category/detail/tests.py | 56 +++++++++---- .../tests/endpoints/category/list/tests.py | 84 +++++++++++-------- .../core/tests/endpoints/post/detail/tests.py | 54 ++++++++---- .../core/tests/endpoints/post/list/tests.py | 36 ++++---- src/newsreader/news/core/tests/test_views.py | 8 +- src/newsreader/news/core/urls.py | 1 - src/newsreader/news/core/views.py | 2 +- src/newsreader/news/urls.py | 19 +++++ src/newsreader/templates/base.html | 4 +- src/newsreader/urls.py | 21 ++--- 22 files changed, 289 insertions(+), 175 deletions(-) create mode 100644 src/newsreader/news/apps.py create mode 100644 src/newsreader/news/urls.py diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 3692deb..6bc2840 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -31,6 +31,7 @@ INSTALLED_APPS = [ "axes", # app modules "newsreader.accounts", + "newsreader.news", "newsreader.news.core", "newsreader.news.collection", ] diff --git a/src/newsreader/news/apps.py b/src/newsreader/news/apps.py new file mode 100644 index 0000000..42c63ba --- /dev/null +++ b/src/newsreader/news/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class NewsConfig(AppConfig): + name = "news" diff --git a/src/newsreader/news/collection/models.py b/src/newsreader/news/collection/models.py index a5bfdfb..3ab9c0d 100644 --- a/src/newsreader/news/collection/models.py +++ b/src/newsreader/news/collection/models.py @@ -10,7 +10,9 @@ class CollectionRule(TimeStampedModel): name = models.CharField(max_length=100) url = models.URLField(max_length=1024) - website_url = models.URLField(max_length=1024, editable=False, blank=True, null=True) + website_url = models.URLField( + max_length=1024, editable=False, blank=True, null=True + ) favicon = models.URLField(blank=True, null=True) timezone = models.CharField( diff --git a/src/newsreader/news/collection/templates/collection/import.html b/src/newsreader/news/collection/templates/collection/import.html index ac8317d..0ca15ab 100644 --- a/src/newsreader/news/collection/templates/collection/import.html +++ b/src/newsreader/news/collection/templates/collection/import.html @@ -28,7 +28,7 @@
      - Cancel + Cancel
      diff --git a/src/newsreader/news/collection/templates/collection/rule.html b/src/newsreader/news/collection/templates/collection/rule.html index 32aa370..c7f56f4 100644 --- a/src/newsreader/news/collection/templates/collection/rule.html +++ b/src/newsreader/news/collection/templates/collection/rule.html @@ -46,7 +46,7 @@
      - Cancel + Cancel {% block confirm-button %}{% endblock %}
      diff --git a/src/newsreader/news/collection/templates/collection/rules.html b/src/newsreader/news/collection/templates/collection/rules.html index 23b3fe7..ee6d539 100644 --- a/src/newsreader/news/collection/templates/collection/rules.html +++ b/src/newsreader/news/collection/templates/collection/rules.html @@ -9,9 +9,9 @@ {% csrf_token %}
      - - - + + +
      @@ -37,7 +37,7 @@ {% endfor %} diff --git a/src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py b/src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py index 1c281d9..02f7334 100644 --- a/src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py +++ b/src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py @@ -17,7 +17,9 @@ class CollectionRuleDetailViewTestCase(TestCase): def test_simple(self): rule = CollectionRuleFactory(user=self.user) - response = self.client.get(reverse("api:rules-detail", args=[rule.pk])) + response = self.client.get( + reverse("api:news:collection:rules-detail", args=[rule.pk]) + ) data = response.json() self.assertEquals(response.status_code, 200) @@ -29,7 +31,9 @@ class CollectionRuleDetailViewTestCase(TestCase): self.assertTrue("category" in data) def test_not_known(self): - response = self.client.get(reverse("api:rules-detail", args=[100])) + response = self.client.get( + reverse("api:news:collection:rules-detail", args=[100]) + ) data = response.json() self.assertEquals(response.status_code, 404) @@ -38,7 +42,9 @@ class CollectionRuleDetailViewTestCase(TestCase): def test_post(self): rule = CollectionRuleFactory(user=self.user) - response = self.client.post(reverse("api:rules-detail", args=[rule.pk])) + response = self.client.post( + reverse("api:news:collection:rules-detail", args=[rule.pk]) + ) data = response.json() self.assertEquals(response.status_code, 405) @@ -48,7 +54,7 @@ class CollectionRuleDetailViewTestCase(TestCase): rule = CollectionRuleFactory(name="BBC", user=self.user) response = self.client.patch( - reverse("api:rules-detail", args=[rule.pk]), + reverse("api:news:collection:rules-detail", args=[rule.pk]), data=json.dumps({"name": "The guardian"}), content_type="application/json", ) @@ -64,7 +70,7 @@ class CollectionRuleDetailViewTestCase(TestCase): rule = CollectionRuleFactory(name="BBC", category=old_category, user=self.user) response = self.client.patch( - reverse("api:rules-detail", args=[rule.pk]), + reverse("api:news:collection:rules-detail", args=[rule.pk]), data=json.dumps({"category": absolute_url}), content_type="application/json", ) @@ -77,7 +83,7 @@ class CollectionRuleDetailViewTestCase(TestCase): rule = CollectionRuleFactory(user=self.user) response = self.client.patch( - reverse("api:rules-detail", args=[rule.pk]), + reverse("api:news:collection:rules-detail", args=[rule.pk]), data=json.dumps({"id": 44}), content_type="application/json", ) @@ -91,7 +97,7 @@ class CollectionRuleDetailViewTestCase(TestCase): category = CategoryFactory(user=self.user) response = self.client.patch( - reverse("api:rules-detail", args=[rule.pk]), + reverse("api:news:collection:rules-detail", args=[rule.pk]), data=json.dumps({"category": category.pk}), content_type="application/json", ) @@ -105,7 +111,7 @@ class CollectionRuleDetailViewTestCase(TestCase): rule = CollectionRuleFactory(name="BBC", user=self.user) response = self.client.put( - reverse("api:rules-detail", args=[rule.pk]), + reverse("api:news:collection:rules-detail", args=[rule.pk]), data=json.dumps({"name": "BBC", "url": "https://www.bbc.co.uk"}), content_type="application/json", ) @@ -117,7 +123,9 @@ class CollectionRuleDetailViewTestCase(TestCase): def test_delete(self): rule = CollectionRuleFactory(user=self.user) - response = self.client.delete(reverse("api:rules-detail", args=[rule.pk])) + response = self.client.delete( + reverse("api:news:collection:rules-detail", args=[rule.pk]) + ) self.assertEquals(response.status_code, 204) @@ -127,7 +135,7 @@ class CollectionRuleDetailViewTestCase(TestCase): rule = CollectionRuleFactory(name="BBC", user=self.user) response = self.client.patch( - reverse("api:rules-detail", args=[rule.pk]), + reverse("api:news:collection:rules-detail", args=[rule.pk]), data=json.dumps({"name": "The guardian"}), content_type="application/json", ) @@ -139,7 +147,7 @@ class CollectionRuleDetailViewTestCase(TestCase): rule = CollectionRuleFactory(name="BBC", user=other_user) response = self.client.patch( - reverse("api:rules-detail", args=[rule.pk]), + reverse("api:news:collection:rules-detail", args=[rule.pk]), data=json.dumps({"name": "The guardian"}), content_type="application/json", ) @@ -152,7 +160,9 @@ class CollectionRuleDetailViewTestCase(TestCase): PostFactory.create_batch(size=20, read=False, rule=rule) PostFactory.create_batch(size=20, read=True, rule=rule) - response = self.client.get(reverse("api:rules-detail", args=[rule.pk])) + response = self.client.get( + reverse("api:news:collection:rules-detail", args=[rule.pk]) + ) data = response.json() self.assertEquals(response.status_code, 200) @@ -169,14 +179,18 @@ class CollectionRuleReadTestCase(TestCase): PostFactory.create_batch(size=20, read=False, rule=rule) - response = self.client.post(reverse("api:rules-read", args=[rule.pk])) + response = self.client.post( + reverse("api:news:collection:rules-read", args=[rule.pk]) + ) data = response.json() self.assertEquals(response.status_code, 201) self.assertEquals(data["unread"], 0) def test_rule_unknown(self): - response = self.client.post(reverse("api:rules-read", args=[101])) + response = self.client.post( + reverse("api:news:collection:rules-read", args=[101]) + ) self.assertEquals(response.status_code, 404) @@ -187,7 +201,9 @@ class CollectionRuleReadTestCase(TestCase): PostFactory.create_batch(size=20, read=False, rule=rule) - response = self.client.post(reverse("api:rules-read", args=[rule.pk])) + response = self.client.post( + reverse("api:news:collection:rules-read", args=[rule.pk]) + ) self.assertEquals(response.status_code, 403) @@ -197,7 +213,9 @@ class CollectionRuleReadTestCase(TestCase): PostFactory.create_batch(size=20, read=False, rule=rule) - response = self.client.post(reverse("api:rules-read", args=[rule.pk])) + response = self.client.post( + reverse("api:news:collection:rules-read", args=[rule.pk]) + ) self.assertEquals(response.status_code, 403) self.assertEquals(Post.objects.filter(read=False).count(), 20) @@ -205,7 +223,9 @@ class CollectionRuleReadTestCase(TestCase): def test_get(self): rule = CollectionRuleFactory(user=self.user) - response = self.client.get(reverse("api:rules-read", args=[rule.pk])) + response = self.client.get( + reverse("api:news:collection:rules-read", args=[rule.pk]) + ) self.assertEquals(response.status_code, 405) @@ -213,7 +233,7 @@ class CollectionRuleReadTestCase(TestCase): rule = CollectionRuleFactory(name="BBC", user=self.user) response = self.client.patch( - reverse("api:rules-read", args=[rule.pk]), + reverse("api:news:collection:rules-read", args=[rule.pk]), data=json.dumps({"name": "Not possible"}), content_type="application/json", ) @@ -224,7 +244,7 @@ class CollectionRuleReadTestCase(TestCase): rule = CollectionRuleFactory(name="BBC", user=self.user) response = self.client.put( - reverse("api:rules-read", args=[rule.pk]), + reverse("api:news:collection:rules-read", args=[rule.pk]), data=json.dumps({"name": "Not possible"}), content_type="application/json", ) @@ -234,6 +254,8 @@ class CollectionRuleReadTestCase(TestCase): def test_delete(self): rule = CollectionRuleFactory(user=self.user) - response = self.client.delete(reverse("api:rules-read", args=[rule.pk])) + response = self.client.delete( + reverse("api:news:collection:rules-read", args=[rule.pk]) + ) self.assertEquals(response.status_code, 405) diff --git a/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py b/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py index 0e2a269..19d2029 100644 --- a/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py +++ b/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py @@ -20,7 +20,7 @@ class RuleListViewTestCase(TestCase): def test_simple(self): CollectionRuleFactory.create_batch(size=3, user=self.user) - response = self.client.get(reverse("api:rules-list")) + response = self.client.get(reverse("api:news:collection:rules-list")) data = response.json() self.assertEquals(response.status_code, 200) @@ -50,7 +50,7 @@ class RuleListViewTestCase(TestCase): ), ] - response = self.client.get(reverse("api:rules-list")) + response = self.client.get(reverse("api:news:collection:rules-list")) data = response.json() self.assertEquals(response.status_code, 200) @@ -65,7 +65,9 @@ class RuleListViewTestCase(TestCase): def test_pagination_count(self): CollectionRuleFactory.create_batch(size=80, user=self.user) - response = self.client.get(reverse("api:rules-list"), {"count": 30}) + response = self.client.get( + reverse("api:news:collection:rules-list"), {"count": 30} + ) data = response.json() self.assertEquals(response.status_code, 200) @@ -73,7 +75,7 @@ class RuleListViewTestCase(TestCase): self.assertEquals(len(data["results"]), 30) def test_empty(self): - response = self.client.get(reverse("api:rules-list")) + response = self.client.get(reverse("api:news:collection:rules-list")) data = response.json() self.assertEquals(response.status_code, 200) @@ -89,7 +91,7 @@ class RuleListViewTestCase(TestCase): data = {"name": "BBC", "url": "https://www.bbc.co.uk", "category": category.pk} response = self.client.post( - reverse("api:rules-list"), + reverse("api:news:collection:rules-list"), data=json.dumps(data), content_type="application/json", ) @@ -99,21 +101,21 @@ class RuleListViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "POST" not allowed.') def test_patch(self): - response = self.client.patch(reverse("api:rules-list")) + response = self.client.patch(reverse("api:news:collection:rules-list")) data = response.json() self.assertEquals(response.status_code, 405) self.assertEquals(data["detail"], 'Method "PATCH" not allowed.') def test_put(self): - response = self.client.put(reverse("api:rules-list")) + response = self.client.put(reverse("api:news:collection:rules-list")) data = response.json() self.assertEquals(response.status_code, 405) self.assertEquals(data["detail"], 'Method "PUT" not allowed.') def test_delete(self): - response = self.client.delete(reverse("api:rules-list")) + response = self.client.delete(reverse("api:news:collection:rules-list")) data = response.json() self.assertEquals(response.status_code, 405) @@ -124,7 +126,7 @@ class RuleListViewTestCase(TestCase): CollectionRuleFactory.create_batch(size=3, user=self.user) - response = self.client.get(reverse("api:rules-list")) + response = self.client.get(reverse("api:news:collection:rules-list")) self.assertEquals(response.status_code, 403) @@ -132,7 +134,7 @@ class RuleListViewTestCase(TestCase): other_user = UserFactory() CollectionRuleFactory.create_batch(size=3, user=other_user) - response = self.client.get(reverse("api:rules-list")) + response = self.client.get(reverse("api:news:collection:rules-list")) data = response.json() self.assertEquals(response.status_code, 200) @@ -151,7 +153,7 @@ class NestedRuleListViewTestCase(TestCase): PostFactory.create_batch(size=5, rule=rule) response = self.client.get( - reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}) + reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}) ) data = response.json() @@ -166,7 +168,8 @@ class NestedRuleListViewTestCase(TestCase): PostFactory.create_batch(size=80, rule=rule) response = self.client.get( - reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}), {"count": 30} + reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}), + {"count": 30}, ) data = response.json() @@ -178,7 +181,7 @@ class NestedRuleListViewTestCase(TestCase): rule = CollectionRuleFactory.create(user=self.user) response = self.client.get( - reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}) + reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}) ) data = response.json() @@ -187,7 +190,9 @@ class NestedRuleListViewTestCase(TestCase): self.assertEquals(len(data["results"]), 0) def test_not_known(self): - response = self.client.get(reverse("api:rules-nested-posts", kwargs={"pk": 0})) + response = self.client.get( + reverse("api:news:collection:rules-nested-posts", kwargs={"pk": 0}) + ) self.assertEquals(response.status_code, 404) @@ -195,7 +200,7 @@ class NestedRuleListViewTestCase(TestCase): rule = CollectionRuleFactory.create(user=self.user) response = self.client.post( - reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}), + reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}), data=json.dumps({}), content_type="application/json", ) @@ -208,7 +213,7 @@ class NestedRuleListViewTestCase(TestCase): rule = CollectionRuleFactory.create(user=self.user) response = self.client.patch( - reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}), + reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}), data=json.dumps({}), content_type="application/json", ) @@ -221,7 +226,7 @@ class NestedRuleListViewTestCase(TestCase): rule = CollectionRuleFactory.create(user=self.user) response = self.client.put( - reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}), + reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}), data=json.dumps({}), content_type="application/json", ) @@ -234,7 +239,7 @@ class NestedRuleListViewTestCase(TestCase): rule = CollectionRuleFactory.create(user=self.user) response = self.client.delete( - reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}), + reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}), data=json.dumps({}), content_type="application/json", ) @@ -249,7 +254,7 @@ class NestedRuleListViewTestCase(TestCase): rule = CollectionRuleFactory(user=self.user) response = self.client.get( - reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}) + reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}) ) self.assertEquals(response.status_code, 403) @@ -259,7 +264,7 @@ class NestedRuleListViewTestCase(TestCase): rule = CollectionRuleFactory(user=other_user) response = self.client.get( - reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}) + reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}) ) self.assertEquals(response.status_code, 403) @@ -294,7 +299,7 @@ class NestedRuleListViewTestCase(TestCase): ] response = self.client.get( - reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}) + reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}) ) data = response.json() @@ -315,7 +320,7 @@ class NestedRuleListViewTestCase(TestCase): PostFactory.create_batch(size=5, rule=other_rule) response = self.client.get( - reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}) + reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}) ) data = response.json() @@ -335,7 +340,8 @@ class NestedRuleListViewTestCase(TestCase): PostFactory.create_batch(size=10, rule=rule, read=True) response = self.client.get( - reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}), {"read": "false"} + reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}), + {"read": "false"}, ) data = response.json() @@ -354,7 +360,8 @@ class NestedRuleListViewTestCase(TestCase): PostFactory.create_batch(size=10, rule=rule, read=True) response = self.client.get( - reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}), {"read": "true"} + reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}), + {"read": "true"}, ) data = response.json() diff --git a/src/newsreader/news/collection/tests/views/test_bulk_views.py b/src/newsreader/news/collection/tests/views/test_bulk_views.py index 7679907..1cbc8ca 100644 --- a/src/newsreader/news/collection/tests/views/test_bulk_views.py +++ b/src/newsreader/news/collection/tests/views/test_bulk_views.py @@ -1,20 +1,16 @@ - -from django.conf import settings from django.test import TestCase from django.urls import reverse from django.utils.translation import gettext_lazy as _ -import pytz from newsreader.accounts.tests.factories import UserFactory from newsreader.news.collection.models import CollectionRule from newsreader.news.collection.tests.factories import CollectionRuleFactory -from newsreader.news.core.tests.factories import CategoryFactory class CollectionRuleBulkViewTestCase: def setUp(self): - self.redirect_url = reverse("rules") + self.redirect_url = reverse("news:collection:rules") self.user = UserFactory() self.client.force_login(self.user) @@ -24,7 +20,7 @@ class CollectionRuleBulkEnableViewTestCase(CollectionRuleBulkViewTestCase, TestC def setUp(self): super().setUp() - self.url = reverse("rules-enable") + self.url = reverse("news:collection:rules-enable") self.rules = CollectionRuleFactory.create_batch( size=5, user=self.user, enabled=False @@ -88,7 +84,8 @@ class CollectionRuleBulkEnableViewTestCase(CollectionRuleBulkViewTestCase, TestC ) self.assertRedirects( - response, f"{reverse('accounts:login')}?next={reverse('rules-enable')}" + response, + f"{reverse('accounts:login')}?next={reverse('news:collection:rules-enable')}", ) rules = CollectionRule.objects.filter(user=self.user) @@ -102,7 +99,7 @@ class CollectionRuleBulkDisableViewTestCase(CollectionRuleBulkViewTestCase, Test def setUp(self): super().setUp() - self.url = reverse("rules-disable") + self.url = reverse("news:collection:rules-disable") self.rules = CollectionRuleFactory.create_batch( size=5, user=self.user, enabled=True @@ -166,7 +163,8 @@ class CollectionRuleBulkDisableViewTestCase(CollectionRuleBulkViewTestCase, Test ) self.assertRedirects( - response, f"{reverse('accounts:login')}?next={reverse('rules-disable')}" + response, + f"{reverse('accounts:login')}?next={reverse('news:collection:rules-disable')}", ) rules = CollectionRule.objects.filter(user=self.user) @@ -180,7 +178,7 @@ class CollectionRuleBulkDeleteViewTestCase(CollectionRuleBulkViewTestCase, TestC def setUp(self): super().setUp() - self.url = reverse("rules-delete") + self.url = reverse("news:collection:rules-delete") self.rules = CollectionRuleFactory.create_batch(size=5, user=self.user) @@ -236,7 +234,8 @@ class CollectionRuleBulkDeleteViewTestCase(CollectionRuleBulkViewTestCase, TestC ) self.assertRedirects( - response, f"{reverse('accounts:login')}?next={reverse('rules-delete')}" + response, + f"{reverse('accounts:login')}?next={reverse('news:collection:rules-delete')}", ) rules = CollectionRule.objects.filter(user=self.user) diff --git a/src/newsreader/news/collection/tests/views/test_import_view.py b/src/newsreader/news/collection/tests/views/test_import_view.py index 57ac502..776e4c6 100644 --- a/src/newsreader/news/collection/tests/views/test_import_view.py +++ b/src/newsreader/news/collection/tests/views/test_import_view.py @@ -5,12 +5,9 @@ from django.test import TestCase from django.urls import reverse from django.utils.translation import gettext_lazy as _ -import pytz - from newsreader.accounts.tests.factories import UserFactory from newsreader.news.collection.models import CollectionRule from newsreader.news.collection.tests.factories import CollectionRuleFactory -from newsreader.news.core.tests.factories import CategoryFactory class OPMLImportTestCase(TestCase): @@ -39,7 +36,9 @@ class OPMLImportTestCase(TestCase): self.assertEquals(len(rules), 4) def test_existing_rules(self): - CollectionRuleFactory(url="http://www.engadget.com/rss-full.xml", user=self.user) + CollectionRuleFactory( + url="http://www.engadget.com/rss-full.xml", user=self.user + ) CollectionRuleFactory(url="https://news.ycombinator.com/rss", user=self.user) CollectionRuleFactory( url="http://feeds.feedburner.com/Techcrunch", user=self.user @@ -61,7 +60,9 @@ class OPMLImportTestCase(TestCase): self.assertEquals(len(rules), 8) def test_skip_existing_rules(self): - CollectionRuleFactory(url="http://www.engadget.com/rss-full.xml", user=self.user) + CollectionRuleFactory( + url="http://www.engadget.com/rss-full.xml", user=self.user + ) CollectionRuleFactory(url="https://news.ycombinator.com/rss", user=self.user) CollectionRuleFactory( url="http://feeds.feedburner.com/Techcrunch", user=self.user diff --git a/src/newsreader/news/collection/views.py b/src/newsreader/news/collection/views.py index ca531fb..3580951 100644 --- a/src/newsreader/news/collection/views.py +++ b/src/newsreader/news/collection/views.py @@ -26,7 +26,7 @@ class CollectionRuleViewMixin: class CollectionRuleDetailMixin: - success_url = reverse_lazy("rules") + success_url = reverse_lazy("news:collection:rules") form_class = CollectionRuleForm def get_context_data(self, **kwargs): @@ -69,7 +69,7 @@ class CollectionRuleBulkView(FormView): form_class = CollectionRuleBulkForm def get_redirect_url(self): - return reverse("rules") + return reverse("news:collection:rules") def get_success_url(self): return self.get_redirect_url() @@ -121,7 +121,7 @@ class CollectionRuleBulkDeleteView(CollectionRuleBulkView): class OPMLImportView(FormView): form_class = OPMLImportForm - success_url = reverse_lazy("rules") + success_url = reverse_lazy("news:collection:rules") template_name = "collection/import.html" def form_valid(self, form): diff --git a/src/newsreader/news/core/templates/core/category.html b/src/newsreader/news/core/templates/core/category.html index 0771345..bee0585 100644 --- a/src/newsreader/news/core/templates/core/category.html +++ b/src/newsreader/news/core/templates/core/category.html @@ -53,7 +53,7 @@
      - Cancel + Cancel {% block confirm-button %}{% endblock %}
      diff --git a/src/newsreader/news/core/tests/endpoints/category/detail/tests.py b/src/newsreader/news/core/tests/endpoints/category/detail/tests.py index 2bd6bcb..864a144 100644 --- a/src/newsreader/news/core/tests/endpoints/category/detail/tests.py +++ b/src/newsreader/news/core/tests/endpoints/category/detail/tests.py @@ -16,7 +16,9 @@ class CategoryDetailViewTestCase(TestCase): def test_simple(self): category = CategoryFactory(user=self.user) - response = self.client.get(reverse("api:categories-detail", args=[category.pk])) + response = self.client.get( + reverse("api:news:core:categories-detail", args=[category.pk]) + ) data = response.json() self.assertEquals(response.status_code, 200) @@ -24,7 +26,9 @@ class CategoryDetailViewTestCase(TestCase): self.assertTrue("name" in data) def test_not_known(self): - response = self.client.get(reverse("api:categories-detail", args=[100])) + response = self.client.get( + reverse("api:news:core:categories-detail", args=[100]) + ) data = response.json() self.assertEquals(response.status_code, 404) @@ -34,7 +38,7 @@ class CategoryDetailViewTestCase(TestCase): category = CategoryFactory(user=self.user) response = self.client.post( - reverse("api:categories-detail", args=[category.pk]) + reverse("api:news:core:categories-detail", args=[category.pk]) ) data = response.json() @@ -45,7 +49,7 @@ class CategoryDetailViewTestCase(TestCase): category = CategoryFactory(name="Clickbait", user=self.user) response = self.client.patch( - reverse("api:categories-detail", args=[category.pk]), + reverse("api:news:core:categories-detail", args=[category.pk]), data=json.dumps({"name": "Interesting posts"}), content_type="application/json", ) @@ -58,7 +62,7 @@ class CategoryDetailViewTestCase(TestCase): category = CategoryFactory(user=self.user) response = self.client.patch( - reverse("api:categories-detail", args=[category.pk]), + reverse("api:news:core:categories-detail", args=[category.pk]), data=json.dumps({"id": 44}), content_type="application/json", ) @@ -71,7 +75,7 @@ class CategoryDetailViewTestCase(TestCase): category = CategoryFactory(name="Clickbait", user=self.user) response = self.client.put( - reverse("api:categories-detail", args=[category.pk]), + reverse("api:news:core:categories-detail", args=[category.pk]), data=json.dumps({"name": "Interesting posts"}), content_type="application/json", ) @@ -84,7 +88,7 @@ class CategoryDetailViewTestCase(TestCase): category = CategoryFactory(user=self.user) response = self.client.delete( - reverse("api:categories-detail", args=[category.pk]) + reverse("api:news:core:categories-detail", args=[category.pk]) ) self.assertEquals(response.status_code, 204) @@ -94,7 +98,9 @@ class CategoryDetailViewTestCase(TestCase): category = CategoryFactory(user=self.user) - response = self.client.get(reverse("api:categories-detail", args=[category.pk])) + response = self.client.get( + reverse("api:news:core:categories-detail", args=[category.pk]) + ) self.assertEquals(response.status_code, 403) @@ -102,7 +108,9 @@ class CategoryDetailViewTestCase(TestCase): other_user = UserFactory() category = CategoryFactory(user=other_user) - response = self.client.get(reverse("api:categories-detail", args=[category.pk])) + response = self.client.get( + reverse("api:news:core:categories-detail", args=[category.pk]) + ) self.assertEquals(response.status_code, 403) @@ -114,7 +122,9 @@ class CategoryDetailViewTestCase(TestCase): PostFactory.create_batch(size=20, read=False, rule=unread_rule) PostFactory.create_batch(size=20, read=True, rule=read_rule) - response = self.client.get(reverse("api:categories-detail", args=[category.pk])) + response = self.client.get( + reverse("api:news:core:categories-detail", args=[category.pk]) + ) data = response.json() self.assertEquals(response.status_code, 200) @@ -133,7 +143,9 @@ class CategoryReadTestCase(TestCase): for rule in CollectionRuleFactory.create_batch(size=5, category=category) ] - response = self.client.post(reverse("api:categories-read", args=[category.pk])) + response = self.client.post( + reverse("api:news:core:categories-read", args=[category.pk]) + ) data = response.json() @@ -142,7 +154,9 @@ class CategoryReadTestCase(TestCase): self.assertEquals(data["id"], category.pk) def test_category_unknown(self): - response = self.client.post(reverse("api:categories-read", args=[101])) + response = self.client.post( + reverse("api:news:core:categories-read", args=[101]) + ) self.assertEquals(response.status_code, 404) @@ -157,7 +171,9 @@ class CategoryReadTestCase(TestCase): ) ] - response = self.client.post(reverse("api:categories-read", args=[category.pk])) + response = self.client.post( + reverse("api:news:core:categories-read", args=[category.pk]) + ) self.assertEquals(response.status_code, 403) @@ -172,14 +188,18 @@ class CategoryReadTestCase(TestCase): ) ] - response = self.client.post(reverse("api:categories-read", args=[category.pk])) + response = self.client.post( + reverse("api:news:core:categories-read", args=[category.pk]) + ) self.assertEquals(response.status_code, 403) def test_get(self): category = CategoryFactory(name="Clickbait", user=self.user) - response = self.client.get(reverse("api:categories-read", args=[category.pk])) + response = self.client.get( + reverse("api:news:core:categories-read", args=[category.pk]) + ) self.assertEquals(response.status_code, 405) @@ -187,7 +207,7 @@ class CategoryReadTestCase(TestCase): category = CategoryFactory(name="Clickbait", user=self.user) response = self.client.patch( - reverse("api:categories-read", args=[category.pk]), + reverse("api:news:core:categories-read", args=[category.pk]), data=json.dumps({"name": "Not possible"}), content_type="application/json", ) @@ -198,7 +218,7 @@ class CategoryReadTestCase(TestCase): category = CategoryFactory(name="Clickbait", user=self.user) response = self.client.put( - reverse("api:categories-read", args=[category.pk]), + reverse("api:news:core:categories-read", args=[category.pk]), data=json.dumps({"name": "Not possible"}), content_type="application/json", ) @@ -209,7 +229,7 @@ class CategoryReadTestCase(TestCase): category = CategoryFactory(name="Clickbait", user=self.user) response = self.client.delete( - reverse("api:categories-read", args=[category.pk]) + reverse("api:news:core:categories-read", args=[category.pk]) ) self.assertEquals(response.status_code, 405) diff --git a/src/newsreader/news/core/tests/endpoints/category/list/tests.py b/src/newsreader/news/core/tests/endpoints/category/list/tests.py index d44f204..aedd5e1 100644 --- a/src/newsreader/news/core/tests/endpoints/category/list/tests.py +++ b/src/newsreader/news/core/tests/endpoints/category/list/tests.py @@ -20,7 +20,7 @@ class CategoryListViewTestCase(TestCase): def test_simple(self): CategoryFactory.create_batch(size=3, user=self.user) - response = self.client.get(reverse("api:categories-list")) + response = self.client.get(reverse("api:news:core:categories-list")) data = response.json() self.assertEquals(response.status_code, 200) @@ -48,7 +48,7 @@ class CategoryListViewTestCase(TestCase): ), ] - response = self.client.get(reverse("api:categories-list")) + response = self.client.get(reverse("api:news:core:categories-list")) data = response.json() self.assertEquals(response.status_code, 200) @@ -58,7 +58,7 @@ class CategoryListViewTestCase(TestCase): self.assertEquals(data[2]["id"], categories[0].pk) def test_empty(self): - response = self.client.get(reverse("api:categories-list")) + response = self.client.get(reverse("api:news:core:categories-list")) data = response.json() self.assertEquals(response.status_code, 200) @@ -68,7 +68,7 @@ class CategoryListViewTestCase(TestCase): data = {"name": "Tech"} response = self.client.post( - reverse("api:categories-list"), + reverse("api:news:core:categories-list"), data=json.dumps(data), content_type="application/json", ) @@ -78,21 +78,21 @@ class CategoryListViewTestCase(TestCase): self.assertEquals(response_data["detail"], 'Method "POST" not allowed.') def test_patch(self): - response = self.client.patch(reverse("api:categories-list")) + response = self.client.patch(reverse("api:news:core:categories-list")) data = response.json() self.assertEquals(response.status_code, 405) self.assertEquals(data["detail"], 'Method "PATCH" not allowed.') def test_put(self): - response = self.client.put(reverse("api:categories-list")) + response = self.client.put(reverse("api:news:core:categories-list")) data = response.json() self.assertEquals(response.status_code, 405) self.assertEquals(data["detail"], 'Method "PUT" not allowed.') def test_delete(self): - response = self.client.delete(reverse("api:categories-list")) + response = self.client.delete(reverse("api:news:core:categories-list")) data = response.json() self.assertEquals(response.status_code, 405) @@ -103,7 +103,7 @@ class CategoryListViewTestCase(TestCase): CategoryFactory.create_batch(size=3, user=self.user) - response = self.client.get(reverse("api:categories-list")) + response = self.client.get(reverse("api:news:core:categories-list")) self.assertEquals(response.status_code, 403) @@ -111,7 +111,7 @@ class CategoryListViewTestCase(TestCase): other_user = UserFactory() CategoryFactory.create_batch(size=3, user=other_user) - response = self.client.get(reverse("api:categories-list")) + response = self.client.get(reverse("api:news:core:categories-list")) data = response.json() self.assertEquals(response.status_code, 200) @@ -128,7 +128,7 @@ class NestedCategoryListViewTestCase(TestCase): rules = CollectionRuleFactory.create_batch(size=5, category=category) response = self.client.get( - reverse("api:categories-nested-rules", kwargs={"pk": category.pk}) + reverse("api:news:core:categories-nested-rules", kwargs={"pk": category.pk}) ) data = response.json() @@ -145,7 +145,7 @@ class NestedCategoryListViewTestCase(TestCase): category = CategoryFactory.create(user=self.user) response = self.client.get( - reverse("api:categories-nested-rules", kwargs={"pk": category.pk}) + reverse("api:news:core:categories-nested-rules", kwargs={"pk": category.pk}) ) data = response.json() @@ -155,14 +155,14 @@ class NestedCategoryListViewTestCase(TestCase): def test_not_known(self): response = self.client.get( - reverse("api:categories-nested-rules", kwargs={"pk": 100}) + reverse("api:news:core:categories-nested-rules", kwargs={"pk": 100}) ) self.assertEquals(response.status_code, 404) def test_post(self): response = self.client.post( - reverse("api:categories-nested-rules", kwargs={"pk": 100}), + reverse("api:news:core:categories-nested-rules", kwargs={"pk": 100}), data=json.dumps({}), content_type="application/json", ) @@ -175,7 +175,9 @@ class NestedCategoryListViewTestCase(TestCase): category = CategoryFactory.create(user=self.user) response = self.client.patch( - reverse("api:categories-nested-rules", kwargs={"pk": category.pk}), + reverse( + "api:news:core:categories-nested-rules", kwargs={"pk": category.pk} + ), data=json.dumps({"name": "test"}), content_type="application/json", ) @@ -188,7 +190,9 @@ class NestedCategoryListViewTestCase(TestCase): category = CategoryFactory.create(user=self.user) response = self.client.put( - reverse("api:categories-nested-rules", kwargs={"pk": category.pk}), + reverse( + "api:news:core:categories-nested-rules", kwargs={"pk": category.pk} + ), data=json.dumps({"name": "test"}), content_type="application/json", ) @@ -201,7 +205,9 @@ class NestedCategoryListViewTestCase(TestCase): category = CategoryFactory.create(user=self.user) response = self.client.delete( - reverse("api:categories-nested-rules", kwargs={"pk": category.pk}), + reverse( + "api:news:core:categories-nested-rules", kwargs={"pk": category.pk} + ), content_type="application/json", ) data = response.json() @@ -216,7 +222,7 @@ class NestedCategoryListViewTestCase(TestCase): rules = CollectionRuleFactory.create_batch(size=5, category=category) response = self.client.get( - reverse("api:categories-nested-rules", kwargs={"pk": category.pk}) + reverse("api:news:core:categories-nested-rules", kwargs={"pk": category.pk}) ) self.assertEquals(response.status_code, 403) @@ -228,7 +234,7 @@ class NestedCategoryListViewTestCase(TestCase): rules = CollectionRuleFactory.create_batch(size=5, category=category) response = self.client.get( - reverse("api:categories-nested-rules", kwargs={"pk": category.pk}) + reverse("api:news:core:categories-nested-rules", kwargs={"pk": category.pk}) ) self.assertEquals(response.status_code, 403) @@ -242,7 +248,7 @@ class NestedCategoryListViewTestCase(TestCase): ] response = self.client.get( - reverse("api:categories-nested-rules", kwargs={"pk": category.pk}) + reverse("api:news:core:categories-nested-rules", kwargs={"pk": category.pk}) ) data = response.json() @@ -265,7 +271,7 @@ class NestedCategoryListViewTestCase(TestCase): ] response = self.client.get( - reverse("api:categories-nested-rules", kwargs={"pk": category.pk}) + reverse("api:news:core:categories-nested-rules", kwargs={"pk": category.pk}) ) data = response.json() @@ -292,7 +298,7 @@ class NestedCategoryPostView(TestCase): } response = self.client.get( - reverse("api:categories-nested-posts", kwargs={"pk": category.pk}) + reverse("api:news:core:categories-nested-posts", kwargs={"pk": category.pk}) ) data = response.json() posts = data["results"] @@ -310,7 +316,7 @@ class NestedCategoryPostView(TestCase): category = CategoryFactory.create(user=self.user) response = self.client.get( - reverse("api:categories-nested-posts", kwargs={"pk": category.pk}) + reverse("api:news:core:categories-nested-posts", kwargs={"pk": category.pk}) ) data = response.json() posts = data["results"] @@ -326,7 +332,7 @@ class NestedCategoryPostView(TestCase): ) response = self.client.get( - reverse("api:categories-nested-posts", kwargs={"pk": category.pk}) + reverse("api:news:core:categories-nested-posts", kwargs={"pk": category.pk}) ) data = response.json() posts = data["results"] @@ -337,14 +343,14 @@ class NestedCategoryPostView(TestCase): def test_not_known(self): response = self.client.get( - reverse("api:categories-nested-posts", kwargs={"pk": 100}) + reverse("api:news:core:categories-nested-posts", kwargs={"pk": 100}) ) self.assertEquals(response.status_code, 404) def test_post(self): response = self.client.post( - reverse("api:categories-nested-posts", kwargs={"pk": 100}), + reverse("api:news:core:categories-nested-posts", kwargs={"pk": 100}), data=json.dumps({}), content_type="application/json", ) @@ -357,7 +363,9 @@ class NestedCategoryPostView(TestCase): category = CategoryFactory.create(user=self.user) response = self.client.patch( - reverse("api:categories-nested-posts", kwargs={"pk": category.pk}), + reverse( + "api:news:core:categories-nested-posts", kwargs={"pk": category.pk} + ), data=json.dumps({}), content_type="application/json", ) @@ -370,7 +378,9 @@ class NestedCategoryPostView(TestCase): category = CategoryFactory.create(user=self.user) response = self.client.put( - reverse("api:categories-nested-posts", kwargs={"pk": category.pk}), + reverse( + "api:news:core:categories-nested-posts", kwargs={"pk": category.pk} + ), data=json.dumps({}), content_type="application/json", ) @@ -383,7 +393,9 @@ class NestedCategoryPostView(TestCase): category = CategoryFactory.create(user=self.user) response = self.client.delete( - reverse("api:categories-nested-posts", kwargs={"pk": category.pk}), + reverse( + "api:news:core:categories-nested-posts", kwargs={"pk": category.pk} + ), content_type="application/json", ) data = response.json() @@ -397,7 +409,7 @@ class NestedCategoryPostView(TestCase): category = CategoryFactory.create(user=self.user) response = self.client.get( - reverse("api:categories-nested-posts", kwargs={"pk": category.pk}) + reverse("api:news:core:categories-nested-posts", kwargs={"pk": category.pk}) ) self.assertEquals(response.status_code, 403) @@ -407,7 +419,7 @@ class NestedCategoryPostView(TestCase): category = CategoryFactory.create(user=other_user) response = self.client.get( - reverse("api:categories-nested-posts", kwargs={"pk": category.pk}) + reverse("api:news:core:categories-nested-posts", kwargs={"pk": category.pk}) ) self.assertEquals(response.status_code, 403) @@ -477,7 +489,7 @@ class NestedCategoryPostView(TestCase): ] response = self.client.get( - reverse("api:categories-nested-posts", kwargs={"pk": category.pk}) + reverse("api:news:core:categories-nested-posts", kwargs={"pk": category.pk}) ) data = response.json() posts = data["results"] @@ -514,7 +526,7 @@ class NestedCategoryPostView(TestCase): ] response = self.client.get( - reverse("api:categories-nested-posts", kwargs={"pk": category.pk}) + reverse("api:news:core:categories-nested-posts", kwargs={"pk": category.pk}) ) data = response.json() posts = data["results"] @@ -533,7 +545,9 @@ class NestedCategoryPostView(TestCase): PostFactory.create_batch(size=10, rule=rule, read=True) response = self.client.get( - reverse("api:categories-nested-posts", kwargs={"pk": category.pk}), + reverse( + "api:news:core:categories-nested-posts", kwargs={"pk": category.pk} + ), {"read": "false"}, ) @@ -554,7 +568,9 @@ class NestedCategoryPostView(TestCase): PostFactory.create_batch(size=10, rule=rule, read=True) response = self.client.get( - reverse("api:categories-nested-posts", kwargs={"pk": category.pk}), + reverse( + "api:news:core:categories-nested-posts", kwargs={"pk": category.pk} + ), {"read": "true"}, ) diff --git a/src/newsreader/news/core/tests/endpoints/post/detail/tests.py b/src/newsreader/news/core/tests/endpoints/post/detail/tests.py index 7c8c31e..c804ff5 100644 --- a/src/newsreader/news/core/tests/endpoints/post/detail/tests.py +++ b/src/newsreader/news/core/tests/endpoints/post/detail/tests.py @@ -19,7 +19,9 @@ class PostDetailViewTestCase(TestCase): ) post = PostFactory(rule=rule) - response = self.client.get(reverse("api:posts-detail", args=[post.pk])) + response = self.client.get( + reverse("api:news:core:posts-detail", args=[post.pk]) + ) data = response.json() self.assertEquals(response.status_code, 200) @@ -34,7 +36,7 @@ class PostDetailViewTestCase(TestCase): self.assertTrue("remoteIdentifier" in data) def test_not_known(self): - response = self.client.get(reverse("api:posts-detail", args=[100])) + response = self.client.get(reverse("api:news:core:posts-detail", args=[100])) data = response.json() self.assertEquals(response.status_code, 404) @@ -46,7 +48,9 @@ class PostDetailViewTestCase(TestCase): ) post = PostFactory(rule=rule) - response = self.client.post(reverse("api:posts-detail", args=[post.pk])) + response = self.client.post( + reverse("api:news:core:posts-detail", args=[post.pk]) + ) data = response.json() self.assertEquals(response.status_code, 405) @@ -59,7 +63,7 @@ class PostDetailViewTestCase(TestCase): post = PostFactory(title="This is clickbait for sure", rule=rule) response = self.client.patch( - reverse("api:posts-detail", args=[post.pk]), + reverse("api:news:core:posts-detail", args=[post.pk]), data=json.dumps({"title": "This title is very accurate"}), content_type="application/json", ) @@ -75,7 +79,7 @@ class PostDetailViewTestCase(TestCase): post = PostFactory(title="This is clickbait for sure", rule=rule) response = self.client.patch( - reverse("api:posts-detail", args=[post.pk]), + reverse("api:news:core:posts-detail", args=[post.pk]), data=json.dumps({"id": 44}), content_type="application/json", ) @@ -94,8 +98,14 @@ class PostDetailViewTestCase(TestCase): post = PostFactory(title="This is clickbait for sure", rule=rule) response = self.client.patch( - reverse("api:posts-detail", args=[post.pk]), - data=json.dumps({"rule": reverse("api:rules-detail", args=[new_rule.pk])}), + reverse("api:news:core:posts-detail", args=[post.pk]), + data=json.dumps( + { + "rule": reverse( + "api:news:collection:rules-detail", args=[new_rule.pk] + ) + } + ), content_type="application/json", ) data = response.json() @@ -111,7 +121,7 @@ class PostDetailViewTestCase(TestCase): post = PostFactory(title="This is clickbait for sure", rule=rule) response = self.client.put( - reverse("api:posts-detail", args=[post.pk]), + reverse("api:news:core:posts-detail", args=[post.pk]), data=json.dumps({"title": "This title is very accurate"}), content_type="application/json", ) @@ -126,7 +136,9 @@ class PostDetailViewTestCase(TestCase): ) post = PostFactory(rule=rule) - response = self.client.delete(reverse("api:posts-detail", args=[post.pk])) + response = self.client.delete( + reverse("api:news:core:posts-detail", args=[post.pk]) + ) data = response.json() self.assertEquals(response.status_code, 405) @@ -138,7 +150,9 @@ class PostDetailViewTestCase(TestCase): rule = CollectionRuleFactory(user=self.user, category=None) post = PostFactory(rule=rule) - response = self.client.get(reverse("api:posts-detail", args=[post.pk])) + response = self.client.get( + reverse("api:news:core:posts-detail", args=[post.pk]) + ) self.assertEquals(response.status_code, 403) @@ -150,7 +164,9 @@ class PostDetailViewTestCase(TestCase): ) post = PostFactory(rule=rule) - response = self.client.get(reverse("api:posts-detail", args=[post.pk])) + response = self.client.get( + reverse("api:news:core:posts-detail", args=[post.pk]) + ) self.assertEquals(response.status_code, 403) @@ -159,7 +175,9 @@ class PostDetailViewTestCase(TestCase): rule = CollectionRuleFactory(user=other_user, category=None) post = PostFactory(rule=rule) - response = self.client.get(reverse("api:posts-detail", args=[post.pk])) + response = self.client.get( + reverse("api:news:core:posts-detail", args=[post.pk]) + ) self.assertEquals(response.status_code, 403) @@ -170,7 +188,9 @@ class PostDetailViewTestCase(TestCase): ) post = PostFactory(rule=rule) - response = self.client.get(reverse("api:posts-detail", args=[post.pk])) + response = self.client.get( + reverse("api:news:core:posts-detail", args=[post.pk]) + ) self.assertEquals(response.status_code, 403) @@ -181,7 +201,9 @@ class PostDetailViewTestCase(TestCase): ) post = PostFactory(rule=rule) - response = self.client.get(reverse("api:posts-detail", args=[post.pk])) + response = self.client.get( + reverse("api:news:core:posts-detail", args=[post.pk]) + ) self.assertEquals(response.status_code, 403) @@ -192,7 +214,7 @@ class PostDetailViewTestCase(TestCase): post = PostFactory(rule=rule, read=False) response = self.client.patch( - reverse("api:posts-detail", args=[post.pk]), + reverse("api:news:core:posts-detail", args=[post.pk]), data=json.dumps({"read": True}), content_type="application/json", ) @@ -208,7 +230,7 @@ class PostDetailViewTestCase(TestCase): post = PostFactory(rule=rule, read=True) response = self.client.patch( - reverse("api:posts-detail", args=[post.pk]), + reverse("api:news:core:posts-detail", args=[post.pk]), data=json.dumps({"read": False}), content_type="application/json", ) diff --git a/src/newsreader/news/core/tests/endpoints/post/list/tests.py b/src/newsreader/news/core/tests/endpoints/post/list/tests.py index f3639bf..3800b64 100644 --- a/src/newsreader/news/core/tests/endpoints/post/list/tests.py +++ b/src/newsreader/news/core/tests/endpoints/post/list/tests.py @@ -21,7 +21,7 @@ class PostListViewTestCase(TestCase): ) PostFactory.create_batch(size=3, rule=rule) - response = self.client.get(reverse("api:posts-list")) + response = self.client.get(reverse("api:news:core:posts-list")) data = response.json() self.assertEquals(response.status_code, 200) @@ -58,7 +58,7 @@ class PostListViewTestCase(TestCase): ), ] - response = self.client.get(reverse("api:posts-list")) + response = self.client.get(reverse("api:news:core:posts-list")) data = response.json() self.assertEquals(response.status_code, 200) @@ -77,7 +77,7 @@ class PostListViewTestCase(TestCase): PostFactory.create_batch(size=80, rule=rule) page_size = 50 - response = self.client.get(reverse("api:posts-list"), {"count": 50}) + response = self.client.get(reverse("api:news:core:posts-list"), {"count": 50}) data = response.json() self.assertEquals(response.status_code, 200) @@ -85,7 +85,7 @@ class PostListViewTestCase(TestCase): self.assertEquals(len(data["results"]), page_size) def test_empty(self): - response = self.client.get(reverse("api:posts-list")) + response = self.client.get(reverse("api:news:core:posts-list")) data = response.json() self.assertEquals(response.status_code, 200) @@ -96,28 +96,28 @@ class PostListViewTestCase(TestCase): self.assertEquals(len(data["results"]), 0) def test_post(self): - response = self.client.post(reverse("api:posts-list")) + response = self.client.post(reverse("api:news:core:posts-list")) data = response.json() self.assertEquals(response.status_code, 405) self.assertEquals(data["detail"], 'Method "POST" not allowed.') def test_patch(self): - response = self.client.patch(reverse("api:posts-list")) + response = self.client.patch(reverse("api:news:core:posts-list")) data = response.json() self.assertEquals(response.status_code, 405) self.assertEquals(data["detail"], 'Method "PATCH" not allowed.') def test_put(self): - response = self.client.put(reverse("api:posts-list")) + response = self.client.put(reverse("api:news:core:posts-list")) data = response.json() self.assertEquals(response.status_code, 405) self.assertEquals(data["detail"], 'Method "PUT" not allowed.') def test_delete(self): - response = self.client.delete(reverse("api:posts-list")) + response = self.client.delete(reverse("api:news:core:posts-list")) data = response.json() self.assertEquals(response.status_code, 405) @@ -128,7 +128,7 @@ class PostListViewTestCase(TestCase): PostFactory.create_batch(size=3, rule=CollectionRuleFactory(user=self.user)) - response = self.client.get(reverse("api:posts-list")) + response = self.client.get(reverse("api:news:core:posts-list")) self.assertEquals(response.status_code, 403) @@ -141,7 +141,7 @@ class PostListViewTestCase(TestCase): size=3, rule=CollectionRuleFactory(user=self.user, category=category) ) - response = self.client.get(reverse("api:posts-list")) + response = self.client.get(reverse("api:news:core:posts-list")) self.assertEquals(response.status_code, 403) @@ -151,7 +151,7 @@ class PostListViewTestCase(TestCase): rule = CollectionRuleFactory(user=other_user, category=None) PostFactory.create_batch(size=3, rule=rule) - response = self.client.get(reverse("api:posts-list")) + response = self.client.get(reverse("api:news:core:posts-list")) data = response.json() self.assertEquals(response.status_code, 200) @@ -166,7 +166,7 @@ class PostListViewTestCase(TestCase): size=3, rule=CollectionRuleFactory(user=other_user, category=category) ) - response = self.client.get(reverse("api:posts-list")) + response = self.client.get(reverse("api:news:core:posts-list")) data = response.json() self.assertEquals(response.status_code, 200) @@ -183,7 +183,7 @@ class PostListViewTestCase(TestCase): ) PostFactory.create_batch(size=3, rule=rule) - response = self.client.get(reverse("api:posts-list")) + response = self.client.get(reverse("api:news:core:posts-list")) data = response.json() self.assertEquals(response.status_code, 200) @@ -195,7 +195,7 @@ class PostListViewTestCase(TestCase): rule = CollectionRuleFactory(user=self.user, category=None) PostFactory.create_batch(size=3, rule=rule) - response = self.client.get(reverse("api:posts-list")) + response = self.client.get(reverse("api:news:core:posts-list")) data = response.json() self.assertEquals(response.status_code, 200) @@ -211,7 +211,9 @@ class PostListViewTestCase(TestCase): PostFactory.create_batch(size=10, rule=rule, read=False) PostFactory.create_batch(size=10, rule=rule, read=True) - response = self.client.get(reverse("api:posts-list"), {"read": "false"}) + response = self.client.get( + reverse("api:news:core:posts-list"), {"read": "false"} + ) data = response.json() posts = data["results"] @@ -230,7 +232,9 @@ class PostListViewTestCase(TestCase): PostFactory.create_batch(size=20, rule=rule, read=False) PostFactory.create_batch(size=10, rule=rule, read=True) - response = self.client.get(reverse("api:posts-list"), {"read": "true"}) + response = self.client.get( + reverse("api:news:core:posts-list"), {"read": "true"} + ) data = response.json() posts = data["results"] diff --git a/src/newsreader/news/core/tests/test_views.py b/src/newsreader/news/core/tests/test_views.py index e4bf458..2601b4a 100644 --- a/src/newsreader/news/core/tests/test_views.py +++ b/src/newsreader/news/core/tests/test_views.py @@ -22,7 +22,7 @@ class CategoryCreateViewTestCase(CategoryViewTestCase, TestCase): def setUp(self): super().setUp() - self.url = reverse("category-create") + self.url = reverse("news:core:category-create") def test_creation(self): rules = CollectionRuleFactory.create_batch(size=4, user=self.user) @@ -88,7 +88,7 @@ class CategoryUpdateViewTestCase(CategoryViewTestCase, TestCase): super().setUp() self.category = CategoryFactory(name="category", user=self.user) - self.url = reverse("category-update", args=[self.category.pk]) + self.url = reverse("news:core:category-update", args=[self.category.pk]) def test_name_change(self): data = {"name": "durp", "user": self.user.pk} @@ -172,7 +172,7 @@ class CategoryUpdateViewTestCase(CategoryViewTestCase, TestCase): other_category.rules.set([*other_rules]) data = {"name": "durp", "user": other_user.pk} - other_url = reverse("category-update", args=[other_category.pk]) + other_url = reverse("news:core:category-update", args=[other_category.pk]) response = self.client.post(other_url, data) self.assertEquals(response.status_code, 404) @@ -218,7 +218,7 @@ class CategoryUpdateViewTestCase(CategoryViewTestCase, TestCase): def test_unique_together(self): other_category = CategoryFactory(name="other category", user=self.user) - url = reverse("category-update", args=[other_category.pk]) + url = reverse("news:core:category-update", args=[other_category.pk]) data = {"name": "category", "user": self.user.pk, "rules": []} response = self.client.post(url, data) diff --git a/src/newsreader/news/core/urls.py b/src/newsreader/news/core/urls.py index 4b92428..8096cf8 100644 --- a/src/newsreader/news/core/urls.py +++ b/src/newsreader/news/core/urls.py @@ -19,7 +19,6 @@ from newsreader.news.core.views import ( urlpatterns = [ - path("", login_required(NewsView.as_view()), name="index"), path("categories/", login_required(CategoryListView.as_view()), name="categories"), path( "categories//", diff --git a/src/newsreader/news/core/views.py b/src/newsreader/news/core/views.py index fde3974..2a75ea7 100644 --- a/src/newsreader/news/core/views.py +++ b/src/newsreader/news/core/views.py @@ -39,7 +39,7 @@ class CategoryViewMixin: class CategoryDetailMixin: - success_url = reverse_lazy("categories") + success_url = reverse_lazy("news:core:categories") form_class = CategoryForm def get_context_data(self, **kwargs): diff --git a/src/newsreader/news/urls.py b/src/newsreader/news/urls.py new file mode 100644 index 0000000..3e16f7e --- /dev/null +++ b/src/newsreader/news/urls.py @@ -0,0 +1,19 @@ +from django.urls import include, path + +from newsreader.news.collection.urls import endpoints as collection_endpoints +from newsreader.news.collection.urls import urlpatterns as collection_urls +from newsreader.news.core.urls import endpoints as core_endpoints +from newsreader.news.core.urls import urlpatterns as core_urls + + +app_name = "news" + +urlpatterns = [ + path("core/", include((core_urls, "core"))), + path("collection/", include((collection_urls, "collection"))), +] + +endpoints = [ + path("", include((core_endpoints, "core"))), + path("", include((collection_endpoints, "collection"))), +] diff --git a/src/newsreader/templates/base.html b/src/newsreader/templates/base.html index 42d438b..cb5ef53 100644 --- a/src/newsreader/templates/base.html +++ b/src/newsreader/templates/base.html @@ -15,8 +15,8 @@
        {% if request.user.is_authenticated %} - - + + {% else %} diff --git a/src/newsreader/urls.py b/src/newsreader/urls.py index c609d91..0779b29 100644 --- a/src/newsreader/urls.py +++ b/src/newsreader/urls.py @@ -1,29 +1,26 @@ from django.conf import settings from django.contrib import admin +from django.contrib.auth.decorators import login_required from django.urls import include, path from drf_yasg import openapi from drf_yasg.views import get_schema_view from newsreader.accounts.urls import urlpatterns as login_urls -from newsreader.news.collection.urls import endpoints as collection_endpoints -from newsreader.news.collection.urls import urlpatterns as collection_patterns -from newsreader.news.core.urls import endpoints as core_endpoints -from newsreader.news.core.urls import urlpatterns as core_patterns +from newsreader.news.core.views import NewsView +from newsreader.news.urls import endpoints as news_endpoints +from newsreader.news.urls import urlpatterns as news_patterns -apipatterns = [ - path("api/", include(core_endpoints)), - path("api/", include(collection_endpoints)), -] +api_patterns = [path("api/", include((news_endpoints, "news")))] schema_info = openapi.Info(title="Newsreader API", default_version="v1") -schema_view = get_schema_view(schema_info, patterns=apipatterns) +schema_view = get_schema_view(schema_info, patterns=api_patterns) urlpatterns = [ - path("", include(core_patterns)), - path("", include(collection_patterns)), - path("", include((apipatterns, "api")), name="api"), + path("", login_required(NewsView.as_view()), name="index"), + path("", include((news_patterns, "news"))), + path("", include((api_patterns, "api"))), path("accounts/", include((login_urls, "accounts")), name="accounts"), path("admin/", admin.site.urls, name="admin"), path("api/", schema_view.with_ui("swagger"), name="api"), From d6d19fa9b92d2018c351fed0f50f27a37ddc4fb6 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 10 May 2020 21:58:39 +0200 Subject: [PATCH 086/422] Fix isort warning --- src/newsreader/news/collection/tests/views/test_bulk_views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/newsreader/news/collection/tests/views/test_bulk_views.py b/src/newsreader/news/collection/tests/views/test_bulk_views.py index 1cbc8ca..39817c2 100644 --- a/src/newsreader/news/collection/tests/views/test_bulk_views.py +++ b/src/newsreader/news/collection/tests/views/test_bulk_views.py @@ -2,7 +2,6 @@ from django.test import TestCase from django.urls import reverse from django.utils.translation import gettext_lazy as _ - from newsreader.accounts.tests.factories import UserFactory from newsreader.news.collection.models import CollectionRule from newsreader.news.collection.tests.factories import CollectionRuleFactory From ed415f2b5c23bbbd9f3c27c98a59cc70d1e6ab36 Mon Sep 17 00:00:00 2001 From: sonny Date: Sun, 10 May 2020 22:36:03 +0200 Subject: [PATCH 087/422] Resolve "User admin" --- src/newsreader/accounts/admin.py | 31 +++++++++++++++++++++++++++++- src/newsreader/templates/base.html | 3 +++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/newsreader/accounts/admin.py b/src/newsreader/accounts/admin.py index 846f6b4..c223687 100644 --- a/src/newsreader/accounts/admin.py +++ b/src/newsreader/accounts/admin.py @@ -1 +1,30 @@ -# Register your models here. +from django.contrib import admin +from django.utils.translation import ugettext as _ + +from newsreader.accounts.models import User + + +class UserAdmin(admin.ModelAdmin): + list_display = ("email", "last_name", "date_joined", "is_active") + list_filter = ("is_active", "is_staff", "is_superuser") + ordering = ("email",) + + search_fields = ["email", "last_name", "first_name"] + readonly_fields = ("last_login", "date_joined") + fieldsets = ( + ( + _("User settings"), + {"fields": ("email", "first_name", "last_name", "is_active")}, + ), + ( + _("Permission settings"), + { + "classes": ("collapse",), + "fields": ("is_staff", "is_superuser", "groups", "user_permissions"), + }, + ), + (_("Misc settings"), {"fields": ("date_joined", "last_login")}), + ) + + +admin.site.register(User, UserAdmin) diff --git a/src/newsreader/templates/base.html b/src/newsreader/templates/base.html index cb5ef53..1e54729 100644 --- a/src/newsreader/templates/base.html +++ b/src/newsreader/templates/base.html @@ -18,6 +18,9 @@ + {% if request.user.is_superuser %} + + {% endif %} {% else %} From 69eaedf89c4c396b2410626af3a249e2fcba8787 Mon Sep 17 00:00:00 2001 From: sonny Date: Sun, 10 May 2020 22:43:37 +0200 Subject: [PATCH 088/422] Rerun autoflake --- src/newsreader/news/collection/tests/views/test_crud.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/newsreader/news/collection/tests/views/test_crud.py b/src/newsreader/news/collection/tests/views/test_crud.py index d77bcf6..a581f0c 100644 --- a/src/newsreader/news/collection/tests/views/test_crud.py +++ b/src/newsreader/news/collection/tests/views/test_crud.py @@ -1,9 +1,5 @@ -import os - -from django.conf import settings from django.test import TestCase from django.urls import reverse -from django.utils.translation import gettext_lazy as _ import pytz From aeb85bd2cfc816f5969efee5375e00aabd943f78 Mon Sep 17 00:00:00 2001 From: sonny Date: Sun, 10 May 2020 22:50:13 +0200 Subject: [PATCH 089/422] Fix old url reference --- .../js/pages/categories/components/CategoryCard.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/newsreader/js/pages/categories/components/CategoryCard.js b/src/newsreader/js/pages/categories/components/CategoryCard.js index a3a242d..94bd6f4 100644 --- a/src/newsreader/js/pages/categories/components/CategoryCard.js +++ b/src/newsreader/js/pages/categories/components/CategoryCard.js @@ -31,7 +31,10 @@ const CategoryCard = props => { const cardContent = <>{category.rules &&
          {categoryRules}
        }; const cardFooter = ( <> - + Edit + + + +{% endblock %} diff --git a/src/newsreader/accounts/templates/accounts/settings.html b/src/newsreader/accounts/templates/accounts/settings.html new file mode 100644 index 0000000..29a2ee9 --- /dev/null +++ b/src/newsreader/accounts/templates/accounts/settings.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block content %} +
        +
        + {% csrf_token %} + +
        +

        {% trans "User settings" %}

        +
        + + {{ form.non_field_errors }} + +
        +
        + + {{ form.first_name.errors }} + {{ form.first_name }} +
        + +
        + + {{ form.last_name.errors }} + {{ form.last_name }} +
        +
        + +
        +
        + Cancel + + + {% trans "Change password" %} + + + +
        +
        + +
        +{% endblock %} diff --git a/src/newsreader/accounts/tests/test_views.py b/src/newsreader/accounts/tests/test_views.py new file mode 100644 index 0000000..d3ac77c --- /dev/null +++ b/src/newsreader/accounts/tests/test_views.py @@ -0,0 +1,29 @@ +from django.test import TestCase +from django.urls import reverse + +from newsreader.accounts.models import User +from newsreader.accounts.tests.factories import UserFactory + + +class UserSettingsViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(password="test") + self.client.force_login(self.user) + + def test_simple(self): + response = self.client.get(reverse("accounts:settings")) + + self.assertEquals(response.status_code, 200) + + def test_user_credential_change(self): + response = self.client.post( + reverse("accounts:settings"), + {"first_name": "First name", "last_name": "Last name"}, + ) + + user = User.objects.get() + + self.assertRedirects(response, reverse("accounts:settings")) + + self.assertEquals(user.first_name, "First name") + self.assertEquals(user.last_name, "Last name") diff --git a/src/newsreader/accounts/urls.py b/src/newsreader/accounts/urls.py index 8605233..d42ae13 100644 --- a/src/newsreader/accounts/urls.py +++ b/src/newsreader/accounts/urls.py @@ -1,3 +1,4 @@ +from django.contrib.auth.decorators import login_required from django.urls import path from newsreader.accounts.views import ( @@ -6,6 +7,7 @@ from newsreader.accounts.views import ( ActivationView, LoginView, LogoutView, + PasswordChangeView, PasswordResetCompleteView, PasswordResetConfirmView, PasswordResetDoneView, @@ -13,6 +15,7 @@ from newsreader.accounts.views import ( RegistrationClosedView, RegistrationCompleteView, RegistrationView, + SettingsView, ) @@ -52,5 +55,10 @@ urlpatterns = [ PasswordResetCompleteView.as_view(), name="password-reset-complete", ), - # TODO: create password change views + path( + "password-change/", + login_required(PasswordChangeView.as_view()), + name="password-change", + ), + path("settings/", login_required(SettingsView.as_view()), name="settings"), ] diff --git a/src/newsreader/accounts/views.py b/src/newsreader/accounts/views.py index 28ae92d..c0342b2 100644 --- a/src/newsreader/accounts/views.py +++ b/src/newsreader/accounts/views.py @@ -2,15 +2,17 @@ from django.contrib.auth import views as django_views from django.shortcuts import render from django.urls import reverse_lazy from django.views.generic import TemplateView +from django.views.generic.edit import FormView, ModelFormMixin from registration.backends.default import views as registration_views +from newsreader.accounts.forms import UserSettingsForm +from newsreader.accounts.models import User + class LoginView(django_views.LoginView): template_name = "accounts/login.html" - - def get_success_url(self): - return reverse_lazy("index") + success_url = reverse_lazy("index") class LogoutView(django_views.LogoutView): @@ -89,3 +91,25 @@ class PasswordResetConfirmView(django_views.PasswordResetConfirmView): class PasswordResetCompleteView(django_views.PasswordResetCompleteView): template_name = "password-reset/password_reset_complete.html" + + +class PasswordChangeView(django_views.PasswordChangeView): + template_name = "accounts/password_change.html" + success_url = reverse_lazy("accounts:settings") + + +class SettingsView(ModelFormMixin, FormView): + template_name = "accounts/settings.html" + success_url = reverse_lazy("accounts:settings") + form_class = UserSettingsForm + model = User + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + return super().get(request, *args, **kwargs) + + def get_object(self, **kwargs): + return self.request.user + + def get_form_kwargs(self): + return {**super().get_form_kwargs(), "instance": self.request.user} diff --git a/src/newsreader/js/pages/categories/App.js b/src/newsreader/js/pages/categories/App.js index 95ab396..691aaed 100644 --- a/src/newsreader/js/pages/categories/App.js +++ b/src/newsreader/js/pages/categories/App.js @@ -80,7 +80,7 @@ class App extends React.Component { const pageHeader = ( <>

        Categories

        - + Create category diff --git a/src/newsreader/news/collection/templates/collection/rules.html b/src/newsreader/news/collection/templates/collection/rules.html index ee6d539..32b6f24 100644 --- a/src/newsreader/news/collection/templates/collection/rules.html +++ b/src/newsreader/news/collection/templates/collection/rules.html @@ -1,7 +1,5 @@ {% extends "base.html" %} -{% load i18n %} - -{% load static %} +{% load i18n static %} {% block content %}
        diff --git a/src/newsreader/scss/components/form/_settings-form.scss b/src/newsreader/scss/components/form/_settings-form.scss new file mode 100644 index 0000000..fc38d70 --- /dev/null +++ b/src/newsreader/scss/components/form/_settings-form.scss @@ -0,0 +1,9 @@ +.settings-form { + &__section:last-child { + & .settings-form__fieldset { + display: flex; + flex-direction: row; + justify-content: space-between; + } + } +} diff --git a/src/newsreader/scss/components/form/index.scss b/src/newsreader/scss/components/form/index.scss index 547da89..1555ae9 100644 --- a/src/newsreader/scss/components/form/index.scss +++ b/src/newsreader/scss/components/form/index.scss @@ -11,3 +11,5 @@ @import "password-reset-form"; @import "password-reset-confirm-form"; + +@import "settings-form"; diff --git a/src/newsreader/scss/components/section/_text-section.scss b/src/newsreader/scss/components/section/_text-section.scss new file mode 100644 index 0000000..88e3e72 --- /dev/null +++ b/src/newsreader/scss/components/section/_text-section.scss @@ -0,0 +1,11 @@ +.text-section { + @extend .section; + + width: 70%; + border-radius: 5px; + + padding: 10px; + + background-color: $white; +} + diff --git a/src/newsreader/scss/components/section/index.scss b/src/newsreader/scss/components/section/index.scss index 4fb6763..0e02686 100644 --- a/src/newsreader/scss/components/section/index.scss +++ b/src/newsreader/scss/components/section/index.scss @@ -1 +1,2 @@ @import "section"; +@import "text-section"; diff --git a/src/newsreader/scss/elements/button/_mixins.scss b/src/newsreader/scss/elements/button/_mixins.scss index 06a912c..75b70e3 100644 --- a/src/newsreader/scss/elements/button/_mixins.scss +++ b/src/newsreader/scss/elements/button/_mixins.scss @@ -1,3 +1,3 @@ @mixin button-padding { - padding: 10px 50px; + padding: 7px 40px; } diff --git a/src/newsreader/scss/pages/index.scss b/src/newsreader/scss/pages/index.scss index 27f0bc6..ddfaf85 100644 --- a/src/newsreader/scss/pages/index.scss +++ b/src/newsreader/scss/pages/index.scss @@ -10,3 +10,5 @@ @import "rule/index"; @import "rules/index"; + +@import "settings/index"; diff --git a/src/newsreader/scss/pages/settings/index.scss b/src/newsreader/scss/pages/settings/index.scss new file mode 100644 index 0000000..28837cd --- /dev/null +++ b/src/newsreader/scss/pages/settings/index.scss @@ -0,0 +1,12 @@ +#settings--page { + .settings-form__fieldset:last-child { + & span { + display: flex; + flex-direction: row; + + & >:first-child { + margin: 0 5px; + } + } + } +} diff --git a/src/newsreader/templates/base.html b/src/newsreader/templates/base.html index 1e54729..3f677c0 100644 --- a/src/newsreader/templates/base.html +++ b/src/newsreader/templates/base.html @@ -17,7 +17,7 @@ - + {% if request.user.is_superuser %} {% endif %} From a4b5373ed254098faa3646aa9329ab552cc52bd4 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sat, 23 May 2020 13:10:46 +0200 Subject: [PATCH 092/422] Add form / card components & refactor forms --- .../accounts/components/login-form.html | 17 ++++ .../accounts/components/settings-form.html | 18 +++++ .../accounts/templates/accounts/login.html | 24 ------ .../templates/accounts/password_change.html | 22 ------ .../accounts/templates/accounts/settings.html | 42 ---------- .../templates/accounts/views/login.html | 7 ++ .../accounts/views/password-change.html | 8 ++ .../templates/accounts/views/settings.html | 7 ++ src/newsreader/accounts/views.py | 18 ++--- src/newsreader/conf/base.py | 2 + src/newsreader/news/collection/forms.py | 2 + .../templates/collection/import.html | 37 --------- .../templates/collection/rule-create.html | 9 --- .../templates/collection/rule-update.html | 9 --- .../collection/templates/collection/rule.html | 55 ------------- .../templates/collection/rules.html | 69 ---------------- .../news/collection/views/import.html | 9 +++ .../news/collection/views/rule-create.html | 9 +++ .../news/collection/views/rule-update.html | 9 +++ .../news/collection/views/rules.html | 79 +++++++++++++++++++ src/newsreader/news/collection/views.py | 8 +- src/newsreader/news/core/forms.py | 20 ++++- .../core/templates/core/category-create.html | 9 --- .../core/templates/core/category-update.html | 9 --- .../news/core/templates/core/category.html | 62 --------------- .../{core => news/core/views}/categories.html | 1 - .../news/core/views/category-create.html | 9 +++ .../news/core/views/category-update.html | 9 +++ .../{core => news/core/views}/homepage.html | 1 - .../templates/news/core/widgets/rule.html | 10 +++ .../templates/news/core/widgets/rules.html | 9 +++ src/newsreader/news/core/views.py | 8 +- .../components/form/_activation-form.scss | 11 --- .../scss/components/form/_category-form.scss | 13 --- .../scss/components/form/_form.scss | 36 ++++++++- .../scss/components/form/_import-form.scss | 17 ---- .../scss/components/form/_login-form.scss | 33 -------- .../scss/components/form/_mixin.scss | 3 + .../form/_password-reset-confirm-form.scss | 3 - .../components/form/_password-reset-form.scss | 18 ----- .../scss/components/form/_register-form.scss | 11 --- .../scss/components/form/_rule-form.scss | 25 ------ .../scss/components/form/_settings-form.scss | 9 --- .../scss/components/form/index.scss | 12 --- src/newsreader/scss/elements/index.scss | 9 ++- .../scss/elements/input/_input.scss | 9 +++ .../scss/elements/select/_select.scss | 13 +++ .../scss/elements/select/index.scss | 1 + src/newsreader/scss/pages/login/index.scss | 25 ++++++ .../templates/components/card/card.html | 13 +++ .../templates/components/card/content.html | 3 + .../templates/components/card/footer.html | 3 + .../templates/components/card/header.html | 3 + .../components/form/cancel-button.html | 5 ++ .../components/form/confirm-button.html | 9 +++ .../templates/components/form/errors.html | 3 + .../templates/components/form/form.html | 43 ++++++++++ .../templates/components/form/help-text.html | 1 + .../templates/components/form/label.html | 3 + .../templates/components/form/title.html | 3 + .../password-reset-complete.html | 13 +++ .../password-reset-confirm.html | 30 +++++++ .../password-reset/password-reset-done.html | 16 ++++ ...t_email.html => password-reset-email.html} | 0 .../password-reset/password-reset-form.html | 11 +++ ...subject.txt => password-reset-subject.txt} | 0 .../password-reset/password-reset.html | 7 ++ .../password_reset_complete.html | 23 ------ .../password_reset_confirm.html | 55 ------------- .../password-reset/password_reset_done.html | 23 ------ .../password-reset/password_reset_form.html | 30 ------- .../registration/activation_complete.html | 27 +++---- .../registration/activation_email.html | 2 +- .../registration/activation_email.txt | 2 +- .../registration/activation_failure.html | 16 +--- .../activation_resend_complete.html | 18 ++--- .../registration/activation_resend_form.html | 32 +------- .../registration/registration_closed.html | 16 +--- .../registration/registration_complete.html | 18 +---- .../registration/registration_form.html | 17 +--- 80 files changed, 525 insertions(+), 775 deletions(-) create mode 100644 src/newsreader/accounts/templates/accounts/components/login-form.html create mode 100644 src/newsreader/accounts/templates/accounts/components/settings-form.html delete mode 100644 src/newsreader/accounts/templates/accounts/login.html delete mode 100644 src/newsreader/accounts/templates/accounts/password_change.html delete mode 100644 src/newsreader/accounts/templates/accounts/settings.html create mode 100644 src/newsreader/accounts/templates/accounts/views/login.html create mode 100644 src/newsreader/accounts/templates/accounts/views/password-change.html create mode 100644 src/newsreader/accounts/templates/accounts/views/settings.html delete mode 100644 src/newsreader/news/collection/templates/collection/import.html delete mode 100644 src/newsreader/news/collection/templates/collection/rule-create.html delete mode 100644 src/newsreader/news/collection/templates/collection/rule-update.html delete mode 100644 src/newsreader/news/collection/templates/collection/rule.html delete mode 100644 src/newsreader/news/collection/templates/collection/rules.html create mode 100644 src/newsreader/news/collection/templates/news/collection/views/import.html create mode 100644 src/newsreader/news/collection/templates/news/collection/views/rule-create.html create mode 100644 src/newsreader/news/collection/templates/news/collection/views/rule-update.html create mode 100644 src/newsreader/news/collection/templates/news/collection/views/rules.html delete mode 100644 src/newsreader/news/core/templates/core/category-create.html delete mode 100644 src/newsreader/news/core/templates/core/category-update.html delete mode 100644 src/newsreader/news/core/templates/core/category.html rename src/newsreader/news/core/templates/{core => news/core/views}/categories.html (99%) create mode 100644 src/newsreader/news/core/templates/news/core/views/category-create.html create mode 100644 src/newsreader/news/core/templates/news/core/views/category-update.html rename src/newsreader/news/core/templates/{core => news/core/views}/homepage.html (99%) create mode 100644 src/newsreader/news/core/templates/news/core/widgets/rule.html create mode 100644 src/newsreader/news/core/templates/news/core/widgets/rules.html delete mode 100644 src/newsreader/scss/components/form/_activation-form.scss delete mode 100644 src/newsreader/scss/components/form/_category-form.scss delete mode 100644 src/newsreader/scss/components/form/_import-form.scss delete mode 100644 src/newsreader/scss/components/form/_login-form.scss create mode 100644 src/newsreader/scss/components/form/_mixin.scss delete mode 100644 src/newsreader/scss/components/form/_password-reset-confirm-form.scss delete mode 100644 src/newsreader/scss/components/form/_password-reset-form.scss delete mode 100644 src/newsreader/scss/components/form/_register-form.scss delete mode 100644 src/newsreader/scss/components/form/_rule-form.scss delete mode 100644 src/newsreader/scss/components/form/_settings-form.scss create mode 100644 src/newsreader/scss/elements/select/_select.scss create mode 100644 src/newsreader/scss/elements/select/index.scss create mode 100644 src/newsreader/templates/components/card/card.html create mode 100644 src/newsreader/templates/components/card/content.html create mode 100644 src/newsreader/templates/components/card/footer.html create mode 100644 src/newsreader/templates/components/card/header.html create mode 100644 src/newsreader/templates/components/form/cancel-button.html create mode 100644 src/newsreader/templates/components/form/confirm-button.html create mode 100644 src/newsreader/templates/components/form/errors.html create mode 100644 src/newsreader/templates/components/form/form.html create mode 100644 src/newsreader/templates/components/form/help-text.html create mode 100644 src/newsreader/templates/components/form/label.html create mode 100644 src/newsreader/templates/components/form/title.html create mode 100755 src/newsreader/templates/password-reset/password-reset-complete.html create mode 100755 src/newsreader/templates/password-reset/password-reset-confirm.html create mode 100755 src/newsreader/templates/password-reset/password-reset-done.html rename src/newsreader/templates/password-reset/{password_reset_email.html => password-reset-email.html} (100%) create mode 100644 src/newsreader/templates/password-reset/password-reset-form.html rename src/newsreader/templates/password-reset/{password_reset_subject.txt => password-reset-subject.txt} (100%) create mode 100644 src/newsreader/templates/password-reset/password-reset.html delete mode 100755 src/newsreader/templates/password-reset/password_reset_complete.html delete mode 100755 src/newsreader/templates/password-reset/password_reset_confirm.html delete mode 100755 src/newsreader/templates/password-reset/password_reset_done.html delete mode 100755 src/newsreader/templates/password-reset/password_reset_form.html diff --git a/src/newsreader/accounts/templates/accounts/components/login-form.html b/src/newsreader/accounts/templates/accounts/components/login-form.html new file mode 100644 index 0000000..87dceb9 --- /dev/null +++ b/src/newsreader/accounts/templates/accounts/components/login-form.html @@ -0,0 +1,17 @@ +{% extends "components/form/form.html" %} +{% load i18n %} + +{% block actions %} +
        +
        + {% include "components/form/cancel-button.html" %} + {% include "components/form/confirm-button.html" %} +
        + +
        + + {% trans "I forgot my password" %} + +
        +
        +{% endblock actions %} diff --git a/src/newsreader/accounts/templates/accounts/components/settings-form.html b/src/newsreader/accounts/templates/accounts/components/settings-form.html new file mode 100644 index 0000000..ff06cb7 --- /dev/null +++ b/src/newsreader/accounts/templates/accounts/components/settings-form.html @@ -0,0 +1,18 @@ +{% extends "components/form/form.html" %} +{% load i18n %} + +{% block actions %} +
        +
        + {% include "components/form/cancel-button.html" %} +
        + +
        + + {% trans "Change password" %} + + + {% include "components/form/confirm-button.html" %} +
        +
        +{% endblock actions %} diff --git a/src/newsreader/accounts/templates/accounts/login.html b/src/newsreader/accounts/templates/accounts/login.html deleted file mode 100644 index ab308b2..0000000 --- a/src/newsreader/accounts/templates/accounts/login.html +++ /dev/null @@ -1,24 +0,0 @@ -{% extends "base.html" %} - -{% load static %} - -{% block content %} -
        - - {% csrf_token %} -
        -

        Login

        -
        - - - - -
        -{% endblock %} diff --git a/src/newsreader/accounts/templates/accounts/password_change.html b/src/newsreader/accounts/templates/accounts/password_change.html deleted file mode 100644 index 1ece1dd..0000000 --- a/src/newsreader/accounts/templates/accounts/password_change.html +++ /dev/null @@ -1,22 +0,0 @@ -{% extends "base.html" %} -{% load static i18n %} - -{% block content %} -
        -
        - {% csrf_token %} - -
        -

        {% trans "Password change" %}

        -
        - -
        - {{ form }} -
        -
        - Cancel - -
        - -
        -{% endblock %} diff --git a/src/newsreader/accounts/templates/accounts/settings.html b/src/newsreader/accounts/templates/accounts/settings.html deleted file mode 100644 index 29a2ee9..0000000 --- a/src/newsreader/accounts/templates/accounts/settings.html +++ /dev/null @@ -1,42 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} - -{% block content %} -
        -
        - {% csrf_token %} - -
        -

        {% trans "User settings" %}

        -
        - - {{ form.non_field_errors }} - -
        -
        - - {{ form.first_name.errors }} - {{ form.first_name }} -
        - -
        - - {{ form.last_name.errors }} - {{ form.last_name }} -
        -
        - -
        -
        - Cancel - - - {% trans "Change password" %} - - - -
        -
        - -
        -{% endblock %} diff --git a/src/newsreader/accounts/templates/accounts/views/login.html b/src/newsreader/accounts/templates/accounts/views/login.html new file mode 100644 index 0000000..b4c391d --- /dev/null +++ b/src/newsreader/accounts/templates/accounts/views/login.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} + +{% block content %} +
        + {% include "accounts/components/login-form.html" with form=form title="Login" confirm_text="Login" %} +
        +{% endblock %} diff --git a/src/newsreader/accounts/templates/accounts/views/password-change.html b/src/newsreader/accounts/templates/accounts/views/password-change.html new file mode 100644 index 0000000..fb8a98b --- /dev/null +++ b/src/newsreader/accounts/templates/accounts/views/password-change.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + +{% block content %} +
        + {% url 'accounts:settings' as cancel_url %} + {% include "components/form/form.html" with form=form title="Change password" confirm_text="Change password" cancel_url=cancel_url %} +
        +{% endblock %} diff --git a/src/newsreader/accounts/templates/accounts/views/settings.html b/src/newsreader/accounts/templates/accounts/views/settings.html new file mode 100644 index 0000000..bf01f8e --- /dev/null +++ b/src/newsreader/accounts/templates/accounts/views/settings.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} + +{% block content %} +
        + {% include "accounts/components/settings-form.html" with form=form title="User profile" confirm_text="Save" %} +
        +{% endblock %} diff --git a/src/newsreader/accounts/views.py b/src/newsreader/accounts/views.py index c0342b2..fed60eb 100644 --- a/src/newsreader/accounts/views.py +++ b/src/newsreader/accounts/views.py @@ -11,7 +11,7 @@ from newsreader.accounts.models import User class LoginView(django_views.LoginView): - template_name = "accounts/login.html" + template_name = "accounts/views/login.html" success_url = reverse_lazy("index") @@ -74,32 +74,32 @@ class ActivationResendView(registration_views.ResendActivationView): # prompts for a new password # PasswordResetCompleteView shows a success message for the above class PasswordResetView(django_views.PasswordResetView): - template_name = "password-reset/password_reset_form.html" - subject_template_name = "password-reset/password_reset_subject.txt" - email_template_name = "password-reset/password_reset_email.html" + template_name = "password-reset/password-reset.html" + subject_template_name = "password-reset/password-reset-subject.txt" + email_template_name = "password-reset/password-reset-email.html" success_url = reverse_lazy("accounts:password-reset-done") class PasswordResetDoneView(django_views.PasswordResetDoneView): - template_name = "password-reset/password_reset_done.html" + template_name = "password-reset/password-reset-done.html" class PasswordResetConfirmView(django_views.PasswordResetConfirmView): - template_name = "password-reset/password_reset_confirm.html" + template_name = "password-reset/password-reset-confirm.html" success_url = reverse_lazy("accounts:password-reset-complete") class PasswordResetCompleteView(django_views.PasswordResetCompleteView): - template_name = "password-reset/password_reset_complete.html" + template_name = "password-reset/password-reset-complete.html" class PasswordChangeView(django_views.PasswordChangeView): - template_name = "accounts/password_change.html" + template_name = "accounts/views/password-change.html" success_url = reverse_lazy("accounts:settings") class SettingsView(ModelFormMixin, FormView): - template_name = "accounts/settings.html" + template_name = "accounts/views/settings.html" success_url = reverse_lazy("accounts:settings") form_class = UserSettingsForm model = User diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 6bc2840..ee5a296 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -172,6 +172,8 @@ AUTH_PASSWORD_VALIDATORS = [ # Authentication user model AUTH_USER_MODEL = "accounts.User" +LOGIN_REDIRECT_URL = "/" + # Internationalization # https://docs.djangoproject.com/en/2.2/topics/i18n/ LANGUAGE_CODE = "en-us" diff --git a/src/newsreader/news/collection/forms.py b/src/newsreader/news/collection/forms.py index bfa0d90..7e5fc97 100644 --- a/src/newsreader/news/collection/forms.py +++ b/src/newsreader/news/collection/forms.py @@ -1,4 +1,5 @@ from django import forms +from django.utils.translation import gettext_lazy as _ import pytz @@ -11,6 +12,7 @@ class CollectionRuleForm(forms.ModelForm): timezone = forms.ChoiceField( widget=forms.Select(attrs={"size": len(pytz.all_timezones)}), choices=((timezone, timezone) for timezone in pytz.all_timezones), + help_text=_("The timezone which the feed uses"), ) def __init__(self, *args, **kwargs): diff --git a/src/newsreader/news/collection/templates/collection/import.html b/src/newsreader/news/collection/templates/collection/import.html deleted file mode 100644 index 0ca15ab..0000000 --- a/src/newsreader/news/collection/templates/collection/import.html +++ /dev/null @@ -1,37 +0,0 @@ -{% extends "base.html" %} - -{% load static i18n %} - -{% block content %} -
        -
        - {% csrf_token %} - {{ form.non_field_errors }} - -
        -

        {% trans "Import an OPML file" %}

        -
        -
        -
        - - {{ form.file.errors }} - {{ form.file }} -
        - -
        - - {{ form.skip_existing }} -
        - -
        - Cancel - -
        -
        - -
        -{% endblock %} diff --git a/src/newsreader/news/collection/templates/collection/rule-create.html b/src/newsreader/news/collection/templates/collection/rule-create.html deleted file mode 100644 index b8db042..0000000 --- a/src/newsreader/news/collection/templates/collection/rule-create.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends "collection/rule.html" %} - -{% block form-header %} -

        Create a rule

        -{% endblock %} - -{% block confirm-button %} - -{% endblock %} diff --git a/src/newsreader/news/collection/templates/collection/rule-update.html b/src/newsreader/news/collection/templates/collection/rule-update.html deleted file mode 100644 index 403f86e..0000000 --- a/src/newsreader/news/collection/templates/collection/rule-update.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends "collection/rule.html" %} - -{% block form-header %} -

        Update rule

        -{% endblock %} - -{% block confirm-button %} - -{% endblock %} diff --git a/src/newsreader/news/collection/templates/collection/rule.html b/src/newsreader/news/collection/templates/collection/rule.html deleted file mode 100644 index c7f56f4..0000000 --- a/src/newsreader/news/collection/templates/collection/rule.html +++ /dev/null @@ -1,55 +0,0 @@ -{% extends "base.html" %} - -{% load static %} - -{% block content %} -
        -
        - {% csrf_token %} - {{ form.non_field_errors }} - -
        - {% block form-header %}{% endblock %} -
        -
        -
        - - {{ form.name.errors }} - {{ form.name }} -
        - -
        - - {{ form.category.errors }} - {{ form.category }} -
        - -
        - - {{ form.url.errors }} - {{ form.url }} -
        - -
        - - {{ form.favicon.errors }} - {{ form.favicon }} -
        - -
        - - The timezone which the feed uses - {{ form.timezone.errors }} - {{ form.timezone }} -
        -
        - -
        -
        - Cancel - {% block confirm-button %}{% endblock %} -
        -
        - -
        -{% endblock %} diff --git a/src/newsreader/news/collection/templates/collection/rules.html b/src/newsreader/news/collection/templates/collection/rules.html deleted file mode 100644 index 32b6f24..0000000 --- a/src/newsreader/news/collection/templates/collection/rules.html +++ /dev/null @@ -1,69 +0,0 @@ -{% extends "base.html" %} -{% load i18n static %} - -{% block content %} -
        -
        - {% csrf_token %} - -
        - - - -
        -
      {{ rule.succeeded }} {{ rule.enabled }} - +
      - - - - - - - - - - - - - {% for rule in rules %} - - - - - - - - - - {% endfor %} - -
      - - {% trans "Name" %}{% trans "Category" %}{% trans "URL" %}{% trans "Successfuly ran" %}{% trans "Enabled" %}
      {{ rule.name }}{{ rule.category.name }}{{ rule.url }}{{ rule.succeeded }}{{ rule.enabled }} - -
      - - -
      +
      +
      +

      {% trans "Page not found" %}

      +
      +
      +

      + Head back to the login page +

      +
      + +
      -{% endblock %} diff --git a/src/newsreader/news/collection/templates/news/collection/views/import.html b/src/newsreader/news/collection/templates/news/collection/views/import.html new file mode 100644 index 0000000..df19887 --- /dev/null +++ b/src/newsreader/news/collection/templates/news/collection/views/import.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
      + {% url "news:collection:rules" as cancel_url %} + {% include "components/form/form.html" with form=form title="Import an OPML file" cancel_url=cancel_url confirm_text="Import rules" %} +
      +{% endblock %} diff --git a/src/newsreader/news/collection/templates/news/collection/views/rule-create.html b/src/newsreader/news/collection/templates/news/collection/views/rule-create.html new file mode 100644 index 0000000..82ed6c5 --- /dev/null +++ b/src/newsreader/news/collection/templates/news/collection/views/rule-create.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
      + {% url "news:collection:rules" as cancel_url %} + {% include "components/form/form.html" with form=form title="Create rule" cancel_url=cancel_url confirm_text="Create rule" %} +
      +{% endblock %} diff --git a/src/newsreader/news/collection/templates/news/collection/views/rule-update.html b/src/newsreader/news/collection/templates/news/collection/views/rule-update.html new file mode 100644 index 0000000..3f0a8fe --- /dev/null +++ b/src/newsreader/news/collection/templates/news/collection/views/rule-update.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
      + {% url "news:collection:rules" as cancel_url %} + {% include "components/form/form.html" with form=form title="Update rule" cancel_url=cancel_url confirm_text="Save rule" %} +
      +{% endblock %} diff --git a/src/newsreader/news/collection/templates/news/collection/views/rules.html b/src/newsreader/news/collection/templates/news/collection/views/rules.html new file mode 100644 index 0000000..a17b818 --- /dev/null +++ b/src/newsreader/news/collection/templates/news/collection/views/rules.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} +{% load i18n static %} + +{% block content %} +
      +
      + {% csrf_token %} + +
      +
      + + + +
      + + +
      + +
      + + + + + + + + + + + + + + {% for rule in rules %} + + + + + + + + + + {% endfor %} + +
      + + {% trans "Name" %}{% trans "Category" %}{% trans "URL" %}{% trans "Successfuly ran" %}{% trans "Enabled" %}
      {{ rule.name }}{{ rule.category.name }}{{ rule.url }}{{ rule.succeeded }}{{ rule.enabled }} + +
      +
      +
      + + +
      +{% endblock %} diff --git a/src/newsreader/news/collection/views.py b/src/newsreader/news/collection/views.py index 3580951..6fb88df 100644 --- a/src/newsreader/news/collection/views.py +++ b/src/newsreader/news/collection/views.py @@ -48,21 +48,21 @@ class CollectionRuleDetailMixin: class CollectionRuleListView(CollectionRuleViewMixin, ListView): paginate_by = 50 - template_name = "collection/rules.html" + template_name = "news/collection/views/rules.html" context_object_name = "rules" class CollectionRuleUpdateView( CollectionRuleViewMixin, CollectionRuleDetailMixin, UpdateView ): - template_name = "collection/rule-update.html" + template_name = "news/collection/views/rule-update.html" context_object_name = "rule" class CollectionRuleCreateView( CollectionRuleViewMixin, CollectionRuleDetailMixin, CreateView ): - template_name = "collection/rule-create.html" + template_name = "news/collection/views/rule-create.html" class CollectionRuleBulkView(FormView): @@ -122,7 +122,7 @@ class CollectionRuleBulkDeleteView(CollectionRuleBulkView): class OPMLImportView(FormView): form_class = OPMLImportForm success_url = reverse_lazy("news:collection:rules") - template_name = "collection/import.html" + template_name = "news/collection/views/import.html" def form_valid(self, form): user = self.request.user diff --git a/src/newsreader/news/core/forms.py b/src/newsreader/news/core/forms.py index a86e2b2..a08022a 100644 --- a/src/newsreader/news/core/forms.py +++ b/src/newsreader/news/core/forms.py @@ -1,15 +1,27 @@ from django import forms +from django.forms.widgets import CheckboxSelectMultiple from newsreader.accounts.models import User from newsreader.news.collection.models import CollectionRule from newsreader.news.core.models import Category +class RulesWidget(CheckboxSelectMultiple): + template_name = "news/core/widgets/rules.html" + option_template_name = "news/core/widgets/rule.html" + + def create_option(self, *args, **kwargs): + option = super().create_option(*args, **kwargs) + instance = self.choices.queryset.get(pk=option["value"]) + + if self.category and instance.category: + option["selected"] = self.category.pk == instance.category.pk + return {**option, "instance": instance} + + class CategoryForm(forms.ModelForm): rules = forms.ModelMultipleChoiceField( - required=False, - queryset=CollectionRule.objects.all(), - widget=forms.widgets.CheckboxSelectMultiple, + required=False, queryset=CollectionRule.objects.none(), widget=RulesWidget ) user = forms.ModelChoiceField( @@ -23,6 +35,8 @@ class CategoryForm(forms.ModelForm): super().__init__(*args, **kwargs) self.fields["rules"].queryset = CollectionRule.objects.filter(user=self.user) + self.fields["rules"].widget.category = self.instance + self.fields["user"].queryset = User.objects.filter(pk=self.user.pk) self.initial["user"] = self.user diff --git a/src/newsreader/news/core/templates/core/category-create.html b/src/newsreader/news/core/templates/core/category-create.html deleted file mode 100644 index 73d05b5..0000000 --- a/src/newsreader/news/core/templates/core/category-create.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends "core/category.html" %} - -{% block form-header %} -

      Create a category

      -{% endblock %} - -{% block confirm-button %} - -{% endblock %} diff --git a/src/newsreader/news/core/templates/core/category-update.html b/src/newsreader/news/core/templates/core/category-update.html deleted file mode 100644 index 3e50df9..0000000 --- a/src/newsreader/news/core/templates/core/category-update.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends "core/category.html" %} - -{% block form-header %} -

      Update category

      -{% endblock %} - -{% block confirm-button %} - -{% endblock %} diff --git a/src/newsreader/news/core/templates/core/category.html b/src/newsreader/news/core/templates/core/category.html deleted file mode 100644 index bee0585..0000000 --- a/src/newsreader/news/core/templates/core/category.html +++ /dev/null @@ -1,62 +0,0 @@ -{% extends "base.html" %} - -{% load static %} - -{% block content %} -
      -
      - {% csrf_token %} - -
      - {% block form-header %}{% endblock %} -
      - - {{ form.non_field_errors }} - {{ form.user.errors }} - {{ form.user }} - -
      -
      - - {{ form.name.errors }} - {{ form.name }} -
      -
      - -
      -
      - - - Note that existing assigned rules will be reassigned to this category - - {{ form.rules.errors }} - -
        - {% for rule in rules %} -
      • - - - {% if rule.favicon %} - - {% else %} - - {% endif %} - - {{ rule.name }} -
      • - {% endfor %} -
      -
      -
      - -
      -
      - Cancel - {% block confirm-button %}{% endblock %} -
      -
      -
      -
      -{% endblock %} diff --git a/src/newsreader/news/core/templates/core/categories.html b/src/newsreader/news/core/templates/news/core/views/categories.html similarity index 99% rename from src/newsreader/news/core/templates/core/categories.html rename to src/newsreader/news/core/templates/news/core/views/categories.html index be4a449..35fc741 100644 --- a/src/newsreader/news/core/templates/core/categories.html +++ b/src/newsreader/news/core/templates/news/core/views/categories.html @@ -1,5 +1,4 @@ {% extends "base.html" %} - {% load static %} {% block content %} diff --git a/src/newsreader/news/core/templates/news/core/views/category-create.html b/src/newsreader/news/core/templates/news/core/views/category-create.html new file mode 100644 index 0000000..6da166f --- /dev/null +++ b/src/newsreader/news/core/templates/news/core/views/category-create.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
      + {% url "news:core:categories" as cancel_url %} + {% include "components/form/form.html" with form=form title="Create category" cancel_url=cancel_url confirm_text="Create category" %} +
      +{% endblock %} diff --git a/src/newsreader/news/core/templates/news/core/views/category-update.html b/src/newsreader/news/core/templates/news/core/views/category-update.html new file mode 100644 index 0000000..1ec1487 --- /dev/null +++ b/src/newsreader/news/core/templates/news/core/views/category-update.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
      + {% url "news:core:categories" as cancel_url %} + {% include "components/form/form.html" with form=form title="Update category" cancel_url=cancel_url confirm_text="Save category" %} +
      +{% endblock %} diff --git a/src/newsreader/news/core/templates/core/homepage.html b/src/newsreader/news/core/templates/news/core/views/homepage.html similarity index 99% rename from src/newsreader/news/core/templates/core/homepage.html rename to src/newsreader/news/core/templates/news/core/views/homepage.html index 8904517..79e1ccc 100644 --- a/src/newsreader/news/core/templates/core/homepage.html +++ b/src/newsreader/news/core/templates/news/core/views/homepage.html @@ -1,5 +1,4 @@ {% extends "base.html" %} - {% load static %} {% block content %} diff --git a/src/newsreader/news/core/templates/news/core/widgets/rule.html b/src/newsreader/news/core/templates/news/core/widgets/rule.html new file mode 100644 index 0000000..b3c7b68 --- /dev/null +++ b/src/newsreader/news/core/templates/news/core/widgets/rule.html @@ -0,0 +1,10 @@ + + +{% if option.instance.favicon %} + +{% else %} + +{% endif %} + +{{ option.label }} diff --git a/src/newsreader/news/core/templates/news/core/widgets/rules.html b/src/newsreader/news/core/templates/news/core/widgets/rules.html new file mode 100644 index 0000000..bbdd43a --- /dev/null +++ b/src/newsreader/news/core/templates/news/core/widgets/rules.html @@ -0,0 +1,9 @@ +
        + {% for group, options, index in widget.optgroups %} + {% for option in options %} +
      • + {% include "news/core/widgets/rule.html" with option=option only %} +
      • + {% endfor %} + {% endfor %} +
      diff --git a/src/newsreader/news/core/views.py b/src/newsreader/news/core/views.py index 2a75ea7..9ef81eb 100644 --- a/src/newsreader/news/core/views.py +++ b/src/newsreader/news/core/views.py @@ -9,7 +9,7 @@ from newsreader.news.core.models import Category class NewsView(TemplateView): - template_name = "core/homepage.html" + template_name = "news/core/views/homepage.html" # TODO serialize objects to show filled main page def get_context_data(self, **kwargs): @@ -55,14 +55,14 @@ class CategoryDetailMixin: class CategoryListView(CategoryViewMixin, ListView): - template_name = "core/categories.html" + template_name = "news/core/views/categories.html" context_object_name = "categories" class CategoryUpdateView(CategoryViewMixin, CategoryDetailMixin, UpdateView): - template_name = "core/category-update.html" + template_name = "news/core/views/category-update.html" context_object_name = "category" class CategoryCreateView(CategoryViewMixin, CategoryDetailMixin, CreateView): - template_name = "core/category-create.html" + template_name = "news/core/views/category-create.html" diff --git a/src/newsreader/scss/components/form/_activation-form.scss b/src/newsreader/scss/components/form/_activation-form.scss deleted file mode 100644 index 39ecc27..0000000 --- a/src/newsreader/scss/components/form/_activation-form.scss +++ /dev/null @@ -1,11 +0,0 @@ -.activation-form { - margin: 10px 0; - & h4 { - padding: 20px 24px 5px 24px; - } - - &__fieldset:last-child { - flex-direction: row; - justify-content: space-between; - } -} diff --git a/src/newsreader/scss/components/form/_category-form.scss b/src/newsreader/scss/components/form/_category-form.scss deleted file mode 100644 index 8132ed2..0000000 --- a/src/newsreader/scss/components/form/_category-form.scss +++ /dev/null @@ -1,13 +0,0 @@ -.category-form { - @extend .form; - - margin: 20px 0; - - &__section:last-child { - & .category-form__fieldset { - display: flex; - flex-direction: row; - justify-content: space-between; - } - } -} diff --git a/src/newsreader/scss/components/form/_form.scss b/src/newsreader/scss/components/form/_form.scss index 5b97958..19e9d4b 100644 --- a/src/newsreader/scss/components/form/_form.scss +++ b/src/newsreader/scss/components/form/_form.scss @@ -1,3 +1,5 @@ +@import "mixin.scss"; + .form { display: flex; flex-direction: column; @@ -8,6 +10,29 @@ font-family: $form-font; background-color: $white; + &__section { + &--last { + & .form__fieldset { + display: flex; + flex-direction: row; + justify-content: space-between; + } + } + + &--actions { + display: flex; + flex-direction: row !important; + + & .form__fieldset { + flex-direction: row; + + & > * { + margin: 0 0 0 5px; + } + } + } + } + &__fieldset { @extend .fieldset; } @@ -16,21 +41,24 @@ display: flex; flex-direction: row; - padding: 15px; + @include form-padding; } &__actions { display: flex; - justify-content: space-between; + flex-direction: row; - width: 50%; - padding: 15px; + @include form-padding; } &__title { font-size: 18px; } + &__intro { + @include form-padding; + } + & .favicon { height: 20px; } diff --git a/src/newsreader/scss/components/form/_import-form.scss b/src/newsreader/scss/components/form/_import-form.scss deleted file mode 100644 index 19acc5c..0000000 --- a/src/newsreader/scss/components/form/_import-form.scss +++ /dev/null @@ -1,17 +0,0 @@ -.import-form { - margin: 20px 0; - - &__fieldset:last-child { - display: flex; - flex-direction: row; - justify-content: space-between; - } - - & input[type=file] { - width: 50%; - } - - & input[type=checkbox] { - margin: 0 auto 0 10px; - } -} diff --git a/src/newsreader/scss/components/form/_login-form.scss b/src/newsreader/scss/components/form/_login-form.scss deleted file mode 100644 index 10e81a0..0000000 --- a/src/newsreader/scss/components/form/_login-form.scss +++ /dev/null @@ -1,33 +0,0 @@ -.login-form { - @extend .form; - - width: 100%; - - h4 { - margin: 0; - padding: 20px 24px 5px 24px; - } - - &__fieldset { - @extend .form__fieldset; - - & label { - @extend .label; - } - - & input { - @extend .input; - } - } - - &__fieldset:last-child { - flex-direction: row-reverse; - justify-content: space-between; - } - - &__fieldset:last-child { - .button { - padding: 10px 50px; - } - } -} diff --git a/src/newsreader/scss/components/form/_mixin.scss b/src/newsreader/scss/components/form/_mixin.scss new file mode 100644 index 0000000..4f55a9e --- /dev/null +++ b/src/newsreader/scss/components/form/_mixin.scss @@ -0,0 +1,3 @@ +@mixin form-padding { + padding: 15px; +} diff --git a/src/newsreader/scss/components/form/_password-reset-confirm-form.scss b/src/newsreader/scss/components/form/_password-reset-confirm-form.scss deleted file mode 100644 index d570c38..0000000 --- a/src/newsreader/scss/components/form/_password-reset-confirm-form.scss +++ /dev/null @@ -1,3 +0,0 @@ -.password-reset-confirm-form { - margin: 20px 0; -} diff --git a/src/newsreader/scss/components/form/_password-reset-form.scss b/src/newsreader/scss/components/form/_password-reset-form.scss deleted file mode 100644 index be92ff4..0000000 --- a/src/newsreader/scss/components/form/_password-reset-form.scss +++ /dev/null @@ -1,18 +0,0 @@ -.password-reset-form { - margin: 20px 0; - - &__fieldset:last-child { - display: flex; - flex-direction: row; - justify-content: space-between; - } - - & .form__header { - display: flex; - flex-direction: column; - } - - & .form__title { - margin: 0 0 5px 0; - } -} diff --git a/src/newsreader/scss/components/form/_register-form.scss b/src/newsreader/scss/components/form/_register-form.scss deleted file mode 100644 index e406ae7..0000000 --- a/src/newsreader/scss/components/form/_register-form.scss +++ /dev/null @@ -1,11 +0,0 @@ -.register-form { - margin: 10px 0; - & h4 { - padding: 20px 24px 5px 24px; - } - - &__fieldset:last-child { - flex-direction: row; - justify-content: space-between; - } -} diff --git a/src/newsreader/scss/components/form/_rule-form.scss b/src/newsreader/scss/components/form/_rule-form.scss deleted file mode 100644 index 82651aa..0000000 --- a/src/newsreader/scss/components/form/_rule-form.scss +++ /dev/null @@ -1,25 +0,0 @@ -.rule-form { - margin: 20px 0; - - &__section:last-child { - & .rule-form__fieldset { - display: flex; - flex-direction: row; - justify-content: space-between; - } - } - - #id_category { - width: 50%; - - padding: 0 10px; - } - - #id_timezone { - max-height: 200px; - width: 50%; - - margin: 0 15px; - padding: 0 10px; - } -} diff --git a/src/newsreader/scss/components/form/_settings-form.scss b/src/newsreader/scss/components/form/_settings-form.scss deleted file mode 100644 index fc38d70..0000000 --- a/src/newsreader/scss/components/form/_settings-form.scss +++ /dev/null @@ -1,9 +0,0 @@ -.settings-form { - &__section:last-child { - & .settings-form__fieldset { - display: flex; - flex-direction: row; - justify-content: space-between; - } - } -} diff --git a/src/newsreader/scss/components/form/index.scss b/src/newsreader/scss/components/form/index.scss index 1555ae9..8069223 100644 --- a/src/newsreader/scss/components/form/index.scss +++ b/src/newsreader/scss/components/form/index.scss @@ -1,15 +1,3 @@ @import "form"; -@import "category-form"; -@import "rule-form"; @import "rules-form"; -@import "import-form"; - -@import "login-form"; -@import "activation-form"; -@import "register-form"; - -@import "password-reset-form"; -@import "password-reset-confirm-form"; - -@import "settings-form"; diff --git a/src/newsreader/scss/elements/index.scss b/src/newsreader/scss/elements/index.scss index f0d7be3..3e2a01c 100644 --- a/src/newsreader/scss/elements/index.scss +++ b/src/newsreader/scss/elements/index.scss @@ -1,10 +1,11 @@ +@import "badge/index"; @import "button/index"; +@import "help-text/index"; +@import "input/index"; +@import "label/index"; @import "link/index"; @import "h1/index"; @import "h2/index"; @import "h3/index"; @import "small/index"; -@import "input/index"; -@import "label/index"; -@import "help-text/index"; -@import "badge/index"; +@import "select/index"; diff --git a/src/newsreader/scss/elements/input/_input.scss b/src/newsreader/scss/elements/input/_input.scss index 1cfb4bb..897fbf9 100644 --- a/src/newsreader/scss/elements/input/_input.scss +++ b/src/newsreader/scss/elements/input/_input.scss @@ -8,6 +8,15 @@ &:focus { border: 1px $focus-blue solid; } + + &[type="file"] { + width: 40%; + } + + &[type="checkbox"] { + align-self: flex-start; + margin: 0 0 0 10px; + } } input { diff --git a/src/newsreader/scss/elements/select/_select.scss b/src/newsreader/scss/elements/select/_select.scss new file mode 100644 index 0000000..d8737b4 --- /dev/null +++ b/src/newsreader/scss/elements/select/_select.scss @@ -0,0 +1,13 @@ +.select { + max-height: 200px; + + &:not([size]){ + width: 40%; + } + + padding: 0 15px; +} + +select { + @extend .select; +} diff --git a/src/newsreader/scss/elements/select/index.scss b/src/newsreader/scss/elements/select/index.scss new file mode 100644 index 0000000..8320088 --- /dev/null +++ b/src/newsreader/scss/elements/select/index.scss @@ -0,0 +1 @@ +@import "select"; diff --git a/src/newsreader/scss/pages/login/index.scss b/src/newsreader/scss/pages/login/index.scss index 69b946e..82b9457 100644 --- a/src/newsreader/scss/pages/login/index.scss +++ b/src/newsreader/scss/pages/login/index.scss @@ -3,4 +3,29 @@ width: 50%; border-radius: 4px; + + & .form { + @extend .form; + + width: 100%; + + h4 { + margin: 0; + padding: 20px 24px 5px 24px; + } + + &__section { + &--last { + flex-direction: row-reverse; + justify-content: space-between; + } + } + + &__fieldset { + @extend .form__fieldset; + + &--last { + } + } + } } diff --git a/src/newsreader/templates/components/card/card.html b/src/newsreader/templates/components/card/card.html new file mode 100644 index 0000000..750bd06 --- /dev/null +++ b/src/newsreader/templates/components/card/card.html @@ -0,0 +1,13 @@ +
      + {% if header_text %} + {% include "components/card/header.html" %} + {% endif %} + + {% if content %} + {% include "components/card/content.html" %} + {% endif %} + + {% if footer_text %} + {% include "components/card/footer.html" %} + {% endif %} +
      diff --git a/src/newsreader/templates/components/card/content.html b/src/newsreader/templates/components/card/content.html new file mode 100644 index 0000000..8ae0141 --- /dev/null +++ b/src/newsreader/templates/components/card/content.html @@ -0,0 +1,3 @@ +
      +

      {{ content }}

      +
      diff --git a/src/newsreader/templates/components/card/footer.html b/src/newsreader/templates/components/card/footer.html new file mode 100644 index 0000000..1acffe0 --- /dev/null +++ b/src/newsreader/templates/components/card/footer.html @@ -0,0 +1,3 @@ + diff --git a/src/newsreader/templates/components/card/header.html b/src/newsreader/templates/components/card/header.html new file mode 100644 index 0000000..567756a --- /dev/null +++ b/src/newsreader/templates/components/card/header.html @@ -0,0 +1,3 @@ +
      +

      {{ header_text }}

      +
      diff --git a/src/newsreader/templates/components/form/cancel-button.html b/src/newsreader/templates/components/form/cancel-button.html new file mode 100644 index 0000000..0891136 --- /dev/null +++ b/src/newsreader/templates/components/form/cancel-button.html @@ -0,0 +1,5 @@ +{% load i18n %} + +{% if cancel_url %} + {% trans "Cancel" %} +{% endif %} diff --git a/src/newsreader/templates/components/form/confirm-button.html b/src/newsreader/templates/components/form/confirm-button.html new file mode 100644 index 0000000..e18b560 --- /dev/null +++ b/src/newsreader/templates/components/form/confirm-button.html @@ -0,0 +1,9 @@ +{% load i18n %} + + diff --git a/src/newsreader/templates/components/form/errors.html b/src/newsreader/templates/components/form/errors.html new file mode 100644 index 0000000..eed67f5 --- /dev/null +++ b/src/newsreader/templates/components/form/errors.html @@ -0,0 +1,3 @@ +
      + {{ errors }} +
      diff --git a/src/newsreader/templates/components/form/form.html b/src/newsreader/templates/components/form/form.html new file mode 100644 index 0000000..d854eb1 --- /dev/null +++ b/src/newsreader/templates/components/form/form.html @@ -0,0 +1,43 @@ +{% load i18n %} + +
      + {% csrf_token %} + + {% if title %} + {% include "components/form/title.html" with title=title only %} + {% endif %} + + {% block intro %} + {% endblock intro %} + + {% if form.non_field_errors %} + {% include "components/form/errors.html" with errors=form.non_field_errors only %} + {% endif %} + + {% block fields %} +
      + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} + + {% for field in form.visible_fields %} +
      + {% include "components/form/label.html" %} + + {{ field.errors }} + {{ field }} + {% include "components/form/help-text.html" %} +
      + {% endfor %} +
      + {% endblock fields %} + + {% block actions %} +
      +
      + {% include "components/form/cancel-button.html" %} + {% include "components/form/confirm-button.html" %} +
      +
      + {% endblock actions %} +
      diff --git a/src/newsreader/templates/components/form/help-text.html b/src/newsreader/templates/components/form/help-text.html new file mode 100644 index 0000000..eb02d82 --- /dev/null +++ b/src/newsreader/templates/components/form/help-text.html @@ -0,0 +1 @@ +{{ field.help_text }} diff --git a/src/newsreader/templates/components/form/label.html b/src/newsreader/templates/components/form/label.html new file mode 100644 index 0000000..4058b29 --- /dev/null +++ b/src/newsreader/templates/components/form/label.html @@ -0,0 +1,3 @@ + diff --git a/src/newsreader/templates/components/form/title.html b/src/newsreader/templates/components/form/title.html new file mode 100644 index 0000000..3adcb75 --- /dev/null +++ b/src/newsreader/templates/components/form/title.html @@ -0,0 +1,3 @@ +
      +

      {{ title }}

      +
      diff --git a/src/newsreader/templates/password-reset/password-reset-complete.html b/src/newsreader/templates/password-reset/password-reset-complete.html new file mode 100755 index 0000000..0b7796f --- /dev/null +++ b/src/newsreader/templates/password-reset/password-reset-complete.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block content %} +
      + {% trans "Password reset complete" as header_text %} + {% blocktrans asvar content %} + You may now log in + {% endblocktrans %} + + {% include "components/card/card.html" with header_text=header_text content=content %} +
      +{% endblock %} diff --git a/src/newsreader/templates/password-reset/password-reset-confirm.html b/src/newsreader/templates/password-reset/password-reset-confirm.html new file mode 100755 index 0000000..d0d5037 --- /dev/null +++ b/src/newsreader/templates/password-reset/password-reset-confirm.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block meta %} + + +{% endblock %} + +{% block content %} +
      + {% if validlink %} + {% url 'accounts:login' as cancel_url %} + {% trans "Enter your new password below to reset your password:" as title %} + {% trans "Change password" as confirm_text %} + {% include "components/form/form.html" with form=form title=title confirm_text=confirm_text cancel_url=cancel_url %} + {% else %} + {% trans "Password reset unsuccessful" as header_text %} + {% url 'accounts:password-reset' as reset_url %} + {% blocktrans asvar content %} + Password reset unsuccessful. Please + try again. + {% endblocktrans %} + + {% include "components/card/card.html" with header_text=header_text content=content %} + {% endif %} +
      +{% endblock %} + +{# This is used by django.contrib.auth #} diff --git a/src/newsreader/templates/password-reset/password-reset-done.html b/src/newsreader/templates/password-reset/password-reset-done.html new file mode 100755 index 0000000..7012439 --- /dev/null +++ b/src/newsreader/templates/password-reset/password-reset-done.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% load static i18n %} + +{% block title %}{% trans "Password reset" %}{% endblock %} + +{% block content %} +
      + {% trans "Password reset" as header_text %} + {% blocktrans asvar content %} + We have sent you an email with a link to reset your password. Please check + your email and click the link to continue. + {% endblocktrans %} + + {% include "components/card/card.html" with header_text=header_text content=content %} +
      +{% endblock %} diff --git a/src/newsreader/templates/password-reset/password_reset_email.html b/src/newsreader/templates/password-reset/password-reset-email.html similarity index 100% rename from src/newsreader/templates/password-reset/password_reset_email.html rename to src/newsreader/templates/password-reset/password-reset-email.html diff --git a/src/newsreader/templates/password-reset/password-reset-form.html b/src/newsreader/templates/password-reset/password-reset-form.html new file mode 100644 index 0000000..e423560 --- /dev/null +++ b/src/newsreader/templates/password-reset/password-reset-form.html @@ -0,0 +1,11 @@ +{% extends "components/form/form.html" %} +{% load i18n %} + +{% block intro %} +

      + {% blocktrans %} + Forgot your password? Enter your email in the form below and we'll send you + instructions for creating a new one. + {% endblocktrans %} +

      +{% endblock intro %} diff --git a/src/newsreader/templates/password-reset/password_reset_subject.txt b/src/newsreader/templates/password-reset/password-reset-subject.txt similarity index 100% rename from src/newsreader/templates/password-reset/password_reset_subject.txt rename to src/newsreader/templates/password-reset/password-reset-subject.txt diff --git a/src/newsreader/templates/password-reset/password-reset.html b/src/newsreader/templates/password-reset/password-reset.html new file mode 100644 index 0000000..97e5678 --- /dev/null +++ b/src/newsreader/templates/password-reset/password-reset.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} + +{% block content %} +
      + {% include "password-reset/password-reset-form.html" with form=form title="Reset password" confirm_text="Reset password" %} +
      +{% endblock %} diff --git a/src/newsreader/templates/password-reset/password_reset_complete.html b/src/newsreader/templates/password-reset/password_reset_complete.html deleted file mode 100755 index 8a47f55..0000000 --- a/src/newsreader/templates/password-reset/password_reset_complete.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends "base.html" %} -{% load static i18n %} - -{% block title %}{% trans "Password reset complete" %}{% endblock %} - -{% block content %} -
      -
      -
      -

      {% trans "Password reset complete" %}

      -
      -
      -

      - {% trans "Your password has been reset!" %} - {% blocktrans %} - You may now log in - {% endblocktrans %}. -

      -
      - -
      -{% endblock %} diff --git a/src/newsreader/templates/password-reset/password_reset_confirm.html b/src/newsreader/templates/password-reset/password_reset_confirm.html deleted file mode 100755 index c438971..0000000 --- a/src/newsreader/templates/password-reset/password_reset_confirm.html +++ /dev/null @@ -1,55 +0,0 @@ -{% extends "base.html" %} -{% load static i18n %} - -{% block meta %} - - -{% endblock %} - -{% block title %}{% trans "Confirm password reset" %}{% endblock %} - -{% block content %} -
      - - {% if validlink %} -
      - {% csrf_token %} -
      -

      - {% trans "Enter your new password below to reset your password:" %} -

      -
      - -
      - {{ form }} -
      -
      - Cancel - -
      -
      - - {% else %} -
      -
      -

      {% trans "Password reset unsuccessful" %}

      -
      -
      -

      - {% url 'accounts:password-reset' as reset_url %} - {% blocktrans %} - Password reset unsuccessful. Please - try again. - {% endblocktrans %} -

      -
      - - {% endif %} - -
      -{% endblock %} - - -{# This is used by django.contrib.auth #} diff --git a/src/newsreader/templates/password-reset/password_reset_done.html b/src/newsreader/templates/password-reset/password_reset_done.html deleted file mode 100755 index dfa141c..0000000 --- a/src/newsreader/templates/password-reset/password_reset_done.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends "base.html" %} -{% load static i18n %} - -{% block title %}{% trans "Password reset" %}{% endblock %} - -{% block content %} -
      -
      -
      -

      {% trans "Password reset" %}

      -
      -
      -

      - {% blocktrans %} - We have sent you an email with a link to reset your password. Please check - your email and click the link to continue. - {% endblocktrans %} -

      -
      - -
      -{% endblock %} diff --git a/src/newsreader/templates/password-reset/password_reset_form.html b/src/newsreader/templates/password-reset/password_reset_form.html deleted file mode 100755 index cd5fc3e..0000000 --- a/src/newsreader/templates/password-reset/password_reset_form.html +++ /dev/null @@ -1,30 +0,0 @@ -{% extends "base.html" %} -{% load static i18n %} - -{% block title %}{% trans "Reset password" %}{% endblock %} - -{% block content %} -
      -
      - {% csrf_token %} -
      -

      {% trans "Reset password" %}

      - -

      - {% blocktrans %} - Forgot your password? Enter your email in the form below and we'll send you - instructions for creating a new one. - {% endblocktrans %} -

      -
      - -
      - {{ form }} -
      -
      - Cancel - -
      -
      -
      -{% endblock %} diff --git a/src/newsreader/templates/registration/activation_complete.html b/src/newsreader/templates/registration/activation_complete.html index 61ea493..f8dd91b 100755 --- a/src/newsreader/templates/registration/activation_complete.html +++ b/src/newsreader/templates/registration/activation_complete.html @@ -1,7 +1,5 @@ {% extends "base.html" %} -{% load static i18n %} - -{% block title %}{% trans "Account Activated" %}{% endblock %} +{% load i18n %} {% comment %} **registration/activation_complete.html** @@ -13,19 +11,14 @@ account is now active. {% block content %}
      -
      -
      -

      {% trans "Account activated" %}

      -
      -
      -

      - {% trans "Your account is now activated." %} - {% if not user.is_authenticated %} - {% trans "You can log in." %} - {% endif %} -

      -
      - + {% trans "Account activated" as header_text %} + + {% if user.is_authenticated %} + {% trans "Your account is activated. You can now log in." as content %} + {% else %} + {% trans "Your account is activated." as content %} + {% endif %} + + {% include "components/card/card.html" with header_text=header_text content=content %}
      {% endblock %} diff --git a/src/newsreader/templates/registration/activation_email.html b/src/newsreader/templates/registration/activation_email.html index 8be4421..8773b29 100644 --- a/src/newsreader/templates/registration/activation_email.html +++ b/src/newsreader/templates/registration/activation_email.html @@ -68,5 +68,5 @@ following context: ``HttpRequest`` instance for better flexibility. For example it can be used to compute absolute register URL: - {{ request.scheme }}://{{ request.get_host }}{% url 'registration_activate' activation_key %} + {{ request.scheme }}://{{ request.get_host }}{% url 'accounts:activate' activation_key %} {% endcomment %} diff --git a/src/newsreader/templates/registration/activation_email.txt b/src/newsreader/templates/registration/activation_email.txt index 7f52a60..d07e785 100644 --- a/src/newsreader/templates/registration/activation_email.txt +++ b/src/newsreader/templates/registration/activation_email.txt @@ -48,5 +48,5 @@ following context: ``HttpRequest`` instance for better flexibility. For example it can be used to compute absolute register URL: - {{ request.scheme }}://{{ request.get_host }}{% url 'registration_activate' activation_key %} + {{ request.scheme }}://{{ request.get_host }}{% url 'accounts:activate' activation_key %} {% endcomment %} diff --git a/src/newsreader/templates/registration/activation_failure.html b/src/newsreader/templates/registration/activation_failure.html index 5cf0f67..c99cc34 100644 --- a/src/newsreader/templates/registration/activation_failure.html +++ b/src/newsreader/templates/registration/activation_failure.html @@ -1,7 +1,5 @@ {% extends "base.html" %} -{% load static i18n %} - -{% block title %}{% trans "Activation Failure" %}{% endblock %} +{% load i18n %} {% comment %} **registration/activate.html** @@ -14,14 +12,8 @@ Used if account activation fails. With the default setup, has the following cont {% block content %}
      -
      -
      -

      {% trans "Activation Failure" %}

      -
      -
      -

      {% trans "Account activation failed." %}

      -
      - + {% trans "Activation Failure" as header_text %} + {% trans "Account activation failed." as content %} + {% include "components/card/card.html" with header_text=header_text content=content %}
      {% endblock %} diff --git a/src/newsreader/templates/registration/activation_resend_complete.html b/src/newsreader/templates/registration/activation_resend_complete.html index dcf1e79..6d01fee 100644 --- a/src/newsreader/templates/registration/activation_resend_complete.html +++ b/src/newsreader/templates/registration/activation_resend_complete.html @@ -14,18 +14,10 @@ the following context: {% block content %}
      -
      -
      -

      {% trans "Account activation resent" %}

      -
      -
      -

      - {% blocktrans %} - We have sent an email to {{ email }} with further instructions. - {% endblocktrans %} -

      -
      - + {% trans "Account activation resent" as header_text %} + {% blocktrans asvar content %} + We have sent an email to {{ email }} with further instructions. + {% endblocktrans %} + {% include "components/card/card.html" with header_text=header_text content=content %}
      {% endblock %} diff --git a/src/newsreader/templates/registration/activation_resend_form.html b/src/newsreader/templates/registration/activation_resend_form.html index f721242..5f0dd82 100644 --- a/src/newsreader/templates/registration/activation_resend_form.html +++ b/src/newsreader/templates/registration/activation_resend_form.html @@ -1,35 +1,9 @@ {% extends "base.html" %} -{% load static i18n %} - -{% block title %}{% trans "Resend Activation Email" %}{% endblock %} - -{% comment %} -**registration/resend_activation_form.html** -Used to show the form users will fill out to resend the activation email. By -default, has the following context: - -``form`` - The registration form. This will be an instance of some subclass - of ``django.forms.Form``; consult `Django's forms documentation - `_ for - information on how to display this in a template. -{% endcomment %} +{% load static %} {% block content %}
      -
      - {% csrf_token %} -
      -

      Resend activation code

      -
      - -
      - {{ form }} -
      -
      - Cancel - -
      -
      + {% url "accounts:login" as cancel_url %} + {% include "components/form/form.html" with form=form title="Resend activation code" cancel_url=cancel_url confirm_text="Resend code" %}
      {% endblock %} diff --git a/src/newsreader/templates/registration/registration_closed.html b/src/newsreader/templates/registration/registration_closed.html index 6169ebe..c7cfd9a 100755 --- a/src/newsreader/templates/registration/registration_closed.html +++ b/src/newsreader/templates/registration/registration_closed.html @@ -1,20 +1,10 @@ {% extends "base.html" %} {% load static i18n %} -{% block title %}{% trans "Registration is closed" %}{% endblock %} - {% block content %}
      -
      -
      -

      {% trans "Registration is closed" %}

      -
      -
      -

      - {% trans "Sorry, but registration is closed at this moment. Come back later." %} -

      -
      - + {% trans "Registration is closed" as header_text %} + {% trans "Sorry, but registration is closed at this moment. Come back later." as content %} + {% include "components/card/card.html" with header_text=header_text content=content %}
      {% endblock %} diff --git a/src/newsreader/templates/registration/registration_complete.html b/src/newsreader/templates/registration/registration_complete.html index cc5f868..ccf70b2 100755 --- a/src/newsreader/templates/registration/registration_complete.html +++ b/src/newsreader/templates/registration/registration_complete.html @@ -1,7 +1,5 @@ {% extends "base.html" %} -{% load static i18n %} - -{% block title %}{% trans "Activation email sent" %}{% endblock %} +{% load i18n %} {% comment %} **registration/registration_complete.html** @@ -14,16 +12,8 @@ been sent. {% block content %}
      -
      -
      -

      {% trans "Activation email sent" %}

      -
      -
      -

      - {% trans "Please check your email to complete the registration process." %} -

      -
      - + {% trans "Activation email sent" as header_text %} + {% trans "Please check your email to complete the registration process." as content %} + {% include "components/card/card.html" with header_text=header_text content=content %}
      {% endblock %} diff --git a/src/newsreader/templates/registration/registration_form.html b/src/newsreader/templates/registration/registration_form.html index 9b8619c..ccc07c9 100644 --- a/src/newsreader/templates/registration/registration_form.html +++ b/src/newsreader/templates/registration/registration_form.html @@ -1,22 +1,9 @@ {% extends "base.html" %} - {% load static %} {% block content %}
      -
      - {% csrf_token %} -
      -

      Register

      -
      - -
      - {{ form }} -
      -
      - Cancel - -
      -
      + {% url "accounts:login" as cancel_url %} + {% include "components/form/form.html" with form=form title="Register" cancel_url=cancel_url confirm_text="Register" %}
      {% endblock %} From e3840342b3fbcc871053883d26ad544598eab946 Mon Sep 17 00:00:00 2001 From: sonny Date: Sat, 23 May 2020 16:53:05 +0200 Subject: [PATCH 093/422] Update duplicate handler & publication date saving --- src/newsreader/news/collection/feed.py | 83 ++++++++---- .../tests/feed/duplicate_handler/tests.py | 123 ++++++++++++++++-- src/newsreader/news/collection/utils.py | 4 +- 3 files changed, 177 insertions(+), 33 deletions(-) diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index 46a7a3b..b14f375 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -1,6 +1,7 @@ import logging from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import timedelta from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist from django.db.models.fields import CharField, TextField @@ -184,6 +185,9 @@ class FeedCollector(Collector): class FeedDuplicateHandler: + duplicate_fields = ("url", "title", "body", "rule") + time_slot_minutes = 10 + def __init__(self, rule): self.queryset = rule.posts.all() @@ -199,39 +203,44 @@ class FeedDuplicateHandler: def check(self, instances): for instance in instances: if instance.remote_identifier in self.existing_identifiers: - existing_post = self.handle_duplicate(instance) + existing_post = self.handle_duplicate_identifier(instance) yield existing_post continue - elif not instance.remote_identifier and self.in_database(instance): - continue + elif self.in_database(instance): + existing_post = self.get_duplicate_in_database(instance) + + if self.in_time_slot(instance, existing_post): + yield self.update_existing_post(instance, existing_post) + continue yield instance def in_database(self, post): - values = { - "url": post.url, - "title": post.title, - "body": post.body, - "publication_date": post.publication_date, - } + values = {field: getattr(post, field, None) for field in self.duplicate_fields} - for existing_post in self.queryset.order_by("-publication_date")[:500]: + for existing_post in self.queryset.filter(**values): if self.is_duplicate(existing_post, values): return True + def in_time_slot(self, instance, existing_post): + time_delta_slot = timedelta(minutes=self.time_slot_minutes) + + time_difference = instance.publication_date - existing_post.publication_date + + if time_difference <= time_delta_slot: + return True + def is_duplicate(self, existing_post, values): - for key, value in values.items(): - existing_value = getattr(existing_post, key, None) - if existing_value != value: - return False + return all( + getattr(existing_post, field, None) == value + for field, value in values.items() + ) - return True - - def handle_duplicate(self, instance): + def handle_duplicate_identifier(self, instance): try: - existing_instance = self.queryset.get( + existing_post = self.queryset.get( remote_identifier=instance.remote_identifier ) except ObjectDoesNotExist: @@ -240,17 +249,43 @@ class FeedDuplicateHandler: ) return instance except MultipleObjectsReturned: - existing_instances = self.queryset.filter( + existing_posts = self.queryset.filter( remote_identifier=instance.remote_identifier ).order_by("-publication_date") - existing_instance = existing_instances.last() - existing_instances.exclude(pk=existing_instance.pk).delete() + existing_post = existing_posts.last() + existing_posts.exclude(pk=existing_post.pk).delete() + updated_post = self.update_existing_post(instance, existing_post) + + return updated_post + + def get_duplicate_in_database(self, instance): + query_values = { + field: getattr(instance, field, None) for field in self.duplicate_fields + } + + try: + existing_post = self.queryset.get(**query_values) + except ObjectDoesNotExist: + logger.error( + f"Duplicate handler tried retrieving post {instance.remote_identifier} but failed doing so." + ) + return instance + except MultipleObjectsReturned: + existing_posts = self.queryset.filter(**query_values).order_by( + "-publication_date" + ) + existing_post = existing_posts.last() + existing_posts.exclude(pk=existing_post.pk).delete() + + return existing_post + + def update_existing_post(self, instance, existing_post): for field in instance._meta.get_fields(): - getattr(existing_instance, field.name, object()) + getattr(existing_post, field.name, object()) new_value = getattr(instance, field.name, object()) if new_value and field.name != "id": - setattr(existing_instance, field.name, new_value) + setattr(existing_post, field.name, new_value) - return existing_instance + return existing_post diff --git a/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py index b794f3e..005771a 100644 --- a/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py +++ b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py @@ -1,8 +1,13 @@ +from datetime import timedelta + from django.test import TestCase from django.utils import timezone +from freezegun import freeze_time + from newsreader.news.collection.feed import FeedDuplicateHandler from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.core.models import Post from newsreader.news.core.tests.factories import PostFactory @@ -25,16 +30,57 @@ class FeedDuplicateHandlerTestCase(TestCase): posts_gen = duplicate_handler.check([new_post]) posts = list(posts_gen) + self.assertEquals(len(posts), 1) + post = posts[0] + existing_post.refresh_from_db() + + self.assertEquals(existing_post.pk, post.pk) + self.assertEquals(post.publication_date, new_post.publication_date) + self.assertEquals(post.title, new_post.title) + self.assertEquals(post.body, new_post.body) + self.assertEquals(post.rule, new_post.rule) + self.assertEquals(post.read, False) + + @freeze_time("2019-10-30 12:30:00") + def test_duplicate_entries_with_different_remote_identifiers(self): + rule = CollectionRuleFactory() + publication_date = timezone.now() + + existing_post = PostFactory.create( + remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", + url="https://bbc.com", + title="New post", + body="Body", + publication_date=publication_date, + rule=rule, + ) + new_post = PostFactory.build( + remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7Q", + url="https://bbc.com", + title="New post", + body="Body", + publication_date=publication_date, + rule=rule, + ) + + with FeedDuplicateHandler(rule) as duplicate_handler: + posts_gen = duplicate_handler.check([new_post]) + posts = list(posts_gen) self.assertEquals(len(posts), 1) + + existing_post.refresh_from_db() + post = posts[0] + + self.assertEquals(existing_post.pk, post.pk) + self.assertEquals(post.title, new_post.title) + self.assertEquals(post.body, new_post.body) + self.assertEquals(post.rule, new_post.rule) self.assertEquals(post.publication_date, new_post.publication_date) - self.assertTrue(post.publication_date != existing_post.publication_date) - self.assertTrue(post.title != existing_post.title) + self.assertEquals(post.read, False) def test_duplicate_entries_in_recent_database(self): - PostFactory.create_batch(size=10) - publication_date = timezone.now() rule = CollectionRuleFactory() @@ -43,7 +89,7 @@ class FeedDuplicateHandlerTestCase(TestCase): title="Birmingham head teacher threatened over LGBT lessons", body="Google's move to end business ties with Huawei will affect current devices", publication_date=publication_date, - remote_identifier=None, + remote_identifier="jabbadabadoe", rule=rule, ) new_post = PostFactory.build( @@ -59,10 +105,19 @@ class FeedDuplicateHandlerTestCase(TestCase): posts_gen = duplicate_handler.check([new_post]) posts = list(posts_gen) - self.assertEquals(len(posts), 0) + self.assertEquals(len(posts), 1) + + existing_post.refresh_from_db() + post = posts[0] + + self.assertEquals(existing_post.pk, post.pk) + self.assertEquals(post.title, new_post.title) + self.assertEquals(post.body, new_post.body) + self.assertEquals(post.rule, new_post.rule) + self.assertEquals(post.publication_date, new_post.publication_date) + self.assertEquals(post.read, False) def test_multiple_existing_entries_with_identifier(self): - timezone.now() rule = CollectionRuleFactory() PostFactory.create_batch( @@ -80,4 +135,56 @@ class FeedDuplicateHandlerTestCase(TestCase): posts = list(posts_gen) self.assertEquals(len(posts), 1) - self.assertEquals(posts[0].title, new_post.title) + + self.assertEquals( + Post.objects.filter( + remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7" + ).count(), + 1, + ) + + post = posts[0] + + self.assertEquals(post.title, new_post.title) + self.assertEquals(post.body, new_post.body) + self.assertEquals(post.publication_date, new_post.publication_date) + self.assertEquals(post.rule, new_post.rule) + self.assertEquals(post.read, False) + + @freeze_time("2019-10-30 12:30:00") + def test_duplicate_entries_outside_time_slot(self): + publication_date = timezone.now() + + rule = CollectionRuleFactory() + existing_post = PostFactory.create( + url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + title="Birmingham head teacher threatened over LGBT lessons", + body="Google's move to end business ties with Huawei will affect current devices", + publication_date=publication_date, + remote_identifier="jabbadabadoe", + rule=rule, + ) + new_post = PostFactory.build( + url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + title="Birmingham head teacher threatened over LGBT lessons", + body="Google's move to end business ties with Huawei will affect current devices", + publication_date=publication_date + timedelta(minutes=12), + remote_identifier=None, + rule=rule, + ) + + with FeedDuplicateHandler(rule) as duplicate_handler: + posts_gen = duplicate_handler.check([new_post]) + posts = list(posts_gen) + + self.assertEquals(len(posts), 1) + + existing_post.refresh_from_db() + post = posts[0] + + self.assertEquals(post.pk, None) + self.assertEquals(post.title, new_post.title) + self.assertEquals(post.body, new_post.body) + self.assertEquals(post.rule, new_post.rule) + self.assertEquals(post.publication_date, new_post.publication_date) + self.assertEquals(post.read, False) diff --git a/src/newsreader/news/collection/utils.py b/src/newsreader/news/collection/utils.py index 0aa096f..fd6ab0a 100644 --- a/src/newsreader/news/collection/utils.py +++ b/src/newsreader/news/collection/utils.py @@ -2,6 +2,7 @@ from datetime import datetime from django.utils import timezone +import pytz import requests from requests.exceptions import RequestException @@ -15,7 +16,8 @@ def build_publication_date(dt, tz): published_parsed = timezone.make_aware(naive_datetime, timezone=tz) except (TypeError, ValueError): return None, False - return published_parsed, True + + return published_parsed.astimezone(pytz.utc), True def fetch(url): From a22ef354be389bb6cb7d1b1bbd0b033fb17ba531 Mon Sep 17 00:00:00 2001 From: sonny Date: Sun, 24 May 2020 13:28:05 +0200 Subject: [PATCH 094/422] Update duplicate checker - deduplicate collected entries - set default fallback publication_date --- .../migrations/0009_auto_20200524_1218.py | 28 ++++++++++++ src/newsreader/accounts/models.py | 2 +- src/newsreader/news/collection/feed.py | 44 ++++++++++++++----- .../collection/tests/feed/collector/tests.py | 6 +-- .../tests/feed/duplicate_handler/tests.py | 44 ++++++++++++++++++- src/newsreader/news/collection/utils.py | 4 +- .../migrations/0006_auto_20200524_1218.py | 18 ++++++++ src/newsreader/news/core/models.py | 5 ++- 8 files changed, 131 insertions(+), 20 deletions(-) create mode 100644 src/newsreader/accounts/migrations/0009_auto_20200524_1218.py create mode 100644 src/newsreader/news/core/migrations/0006_auto_20200524_1218.py diff --git a/src/newsreader/accounts/migrations/0009_auto_20200524_1218.py b/src/newsreader/accounts/migrations/0009_auto_20200524_1218.py new file mode 100644 index 0000000..3b01b0f --- /dev/null +++ b/src/newsreader/accounts/migrations/0009_auto_20200524_1218.py @@ -0,0 +1,28 @@ +# Generated by Django 3.0.5 on 2020-05-24 10:18 + +import django.db.models.deletion + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("django_celery_beat", "0012_periodictask_expire_seconds"), + ("accounts", "0008_auto_20200422_2243"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="task", + field=models.OneToOneField( + blank=True, + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="django_celery_beat.PeriodicTask", + verbose_name="collection task", + ), + ) + ] diff --git a/src/newsreader/accounts/models.py b/src/newsreader/accounts/models.py index 0b2799f..18eba07 100644 --- a/src/newsreader/accounts/models.py +++ b/src/newsreader/accounts/models.py @@ -43,7 +43,7 @@ class User(AbstractUser): task = models.OneToOneField( PeriodicTask, - on_delete=models.SET_NULL, + on_delete=models.CASCADE, null=True, blank=True, editable=False, diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index b14f375..07090ce 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -47,13 +47,9 @@ class FeedBuilder(Builder): def create_posts(self, stream): data, stream = stream - entries = [] with FeedDuplicateHandler(stream.rule) as duplicate_handler: - try: - entries = data["entries"] - except KeyError: - pass + entries = data.get("entries", []) instances = self.build(entries, stream.rule) posts = duplicate_handler.check(instances) @@ -82,11 +78,9 @@ class FeedBuilder(Builder): value = self.truncate_text(model_field, entry[field]) if field == "published_parsed": - aware_datetime, created = build_publication_date(value, tz) - data[model_field] = aware_datetime if created else None + data[model_field] = build_publication_date(value, tz) elif field == "summary": - summary = self.sanitize_fragment(value) - data[model_field] = summary + data[model_field] = self.sanitize_fragment(value) else: data[model_field] = value @@ -201,7 +195,9 @@ class FeedDuplicateHandler: pass def check(self, instances): - for instance in instances: + deduplicated_instances = self.deduplicate_instances(instances) + + for instance in deduplicated_instances: if instance.remote_identifier in self.existing_identifiers: existing_post = self.handle_duplicate_identifier(instance) @@ -232,6 +228,34 @@ class FeedDuplicateHandler: if time_difference <= time_delta_slot: return True + def deduplicate_instances(self, instances): + deduplicated_instances = [] + + for instance in instances: + values = { + field: getattr(instance, field, None) for field in self.duplicate_fields + } + duplicate = False + + for deduplicated_instance in deduplicated_instances: + deduplicated_identifier = deduplicated_instance.remote_identifier + instance_identifier = instance.remote_identifier + has_identifiers = deduplicated_identifier and instance_identifier + + if self.is_duplicate(deduplicated_instance, values): + duplicate = True + break + elif has_identifiers and deduplicated_identifier == instance_identifier: + duplicate = True + break + + if duplicate: + continue + + deduplicated_instances.append(instance) + + return deduplicated_instances + def is_duplicate(self, existing_post, values): return all( getattr(existing_post, field, None) == value diff --git a/src/newsreader/news/collection/tests/feed/collector/tests.py b/src/newsreader/news/collection/tests/feed/collector/tests.py index 88f2875..0506783 100644 --- a/src/newsreader/news/collection/tests/feed/collector/tests.py +++ b/src/newsreader/news/collection/tests/feed/collector/tests.py @@ -139,7 +139,7 @@ class FeedCollectorTestCase(TestCase): self.mocked_parse.return_value = duplicate_mock rule = CollectionRuleFactory() - aware_datetime, _ = build_publication_date( + aware_datetime = build_publication_date( struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), pytz.utc ) @@ -152,7 +152,7 @@ class FeedCollectorTestCase(TestCase): rule=rule, ) - aware_datetime, _ = build_publication_date( + aware_datetime = build_publication_date( struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0)), pytz.utc ) @@ -165,7 +165,7 @@ class FeedCollectorTestCase(TestCase): rule=rule, ) - aware_datetime, _ = build_publication_date( + aware_datetime = build_publication_date( struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), pytz.utc ) diff --git a/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py index 005771a..6ed8a59 100644 --- a/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py +++ b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py @@ -89,7 +89,7 @@ class FeedDuplicateHandlerTestCase(TestCase): title="Birmingham head teacher threatened over LGBT lessons", body="Google's move to end business ties with Huawei will affect current devices", publication_date=publication_date, - remote_identifier="jabbadabadoe", + remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", rule=rule, ) new_post = PostFactory.build( @@ -161,7 +161,7 @@ class FeedDuplicateHandlerTestCase(TestCase): title="Birmingham head teacher threatened over LGBT lessons", body="Google's move to end business ties with Huawei will affect current devices", publication_date=publication_date, - remote_identifier="jabbadabadoe", + remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", rule=rule, ) new_post = PostFactory.build( @@ -188,3 +188,43 @@ class FeedDuplicateHandlerTestCase(TestCase): self.assertEquals(post.rule, new_post.rule) self.assertEquals(post.publication_date, new_post.publication_date) self.assertEquals(post.read, False) + + def test_duplicate_entries_in_collected_entries(self): + rule = CollectionRuleFactory() + post_1 = PostFactory.build( + title="title got updated", body="body", url="https://bbc.com", rule=rule + ) + duplicate_post_1 = PostFactory.build( + title="title got updated", body="body", url="https://bbc.com", rule=rule + ) + + post_2 = PostFactory.build( + remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7" + ) + duplicate_post_2 = PostFactory.build( + remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7" + ) + + collected_posts = (post_1, post_2, duplicate_post_1, duplicate_post_2) + + with FeedDuplicateHandler(rule) as duplicate_handler: + posts_gen = duplicate_handler.check(collected_posts) + posts = list(posts_gen) + + self.assertEquals(len(posts), 2) + + post = posts[0] + + self.assertEquals(post_1.publication_date, post.publication_date) + self.assertEquals(post_1.title, post.title) + self.assertEquals(post_1.body, post.body) + self.assertEquals(post_1.rule, post.rule) + self.assertEquals(post.read, False) + + post = posts[1] + + self.assertEquals(post_2.publication_date, post.publication_date) + self.assertEquals(post_2.title, post.title) + self.assertEquals(post_2.body, post.body) + self.assertEquals(post_2.rule, post.rule) + self.assertEquals(post.read, False) diff --git a/src/newsreader/news/collection/utils.py b/src/newsreader/news/collection/utils.py index fd6ab0a..9a2e456 100644 --- a/src/newsreader/news/collection/utils.py +++ b/src/newsreader/news/collection/utils.py @@ -15,9 +15,9 @@ def build_publication_date(dt, tz): naive_datetime = datetime(*dt[:6]) published_parsed = timezone.make_aware(naive_datetime, timezone=tz) except (TypeError, ValueError): - return None, False + return timezone.now() - return published_parsed.astimezone(pytz.utc), True + return published_parsed.astimezone(pytz.utc) def fetch(url): diff --git a/src/newsreader/news/core/migrations/0006_auto_20200524_1218.py b/src/newsreader/news/core/migrations/0006_auto_20200524_1218.py new file mode 100644 index 0000000..f90b205 --- /dev/null +++ b/src/newsreader/news/core/migrations/0006_auto_20200524_1218.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.5 on 2020-05-24 10:18 + +import django.utils.timezone + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("core", "0005_auto_20200412_1955")] + + operations = [ + migrations.AlterField( + model_name="post", + name="publication_date", + field=models.DateTimeField(default=django.utils.timezone.now), + ) + ] diff --git a/src/newsreader/news/core/models.py b/src/newsreader/news/core/models.py index 64028d2..28bf3fd 100644 --- a/src/newsreader/news/core/models.py +++ b/src/newsreader/news/core/models.py @@ -1,4 +1,5 @@ from django.db import models +from django.utils import timezone from django.utils.translation import gettext as _ from newsreader.core.models import TimeStampedModel @@ -9,7 +10,7 @@ class Post(TimeStampedModel): title = models.CharField(max_length=200, blank=True, null=True) body = models.TextField(blank=True, null=True) author = models.CharField(max_length=40, blank=True, null=True) - publication_date = models.DateTimeField(blank=True, null=True) + publication_date = models.DateTimeField(default=timezone.now) url = models.URLField(max_length=1024, blank=True, null=True) read = models.BooleanField(default=False) @@ -18,7 +19,7 @@ class Post(TimeStampedModel): CollectionRule, on_delete=models.CASCADE, editable=False, related_name="posts" ) remote_identifier = models.CharField( - max_length=500, blank=True, null=True, editable=False + max_length=500, editable=False, blank=True, null=True ) def __str__(self): From ddfd208d1c30c766427326a083627bc2125bd50d Mon Sep 17 00:00:00 2001 From: sonny Date: Wed, 3 Jun 2020 20:46:01 +0200 Subject: [PATCH 095/422] Merge frontend redesign --- package-lock.json | 371 ++++++++++++------ package.json | 4 +- src/newsreader/assets/fonts/METADATA.pb | 101 +++++ src/newsreader/assets/fonts/Rubik-Black.ttf | Bin 0 -> 510760 bytes .../assets/fonts/Rubik-BlackItalic.ttf | Bin 0 -> 540716 bytes src/newsreader/assets/fonts/Rubik-Bold.ttf | Bin 0 -> 529376 bytes .../assets/fonts/Rubik-BoldItalic.ttf | Bin 0 -> 574128 bytes src/newsreader/assets/fonts/Rubik-Italic.ttf | Bin 0 -> 582340 bytes src/newsreader/assets/fonts/Rubik-Light.ttf | Bin 0 -> 523812 bytes .../assets/fonts/Rubik-LightItalic.ttf | Bin 0 -> 586072 bytes src/newsreader/assets/fonts/Rubik-Medium.ttf | Bin 0 -> 527560 bytes .../assets/fonts/Rubik-MediumItalic.ttf | Bin 0 -> 578196 bytes src/newsreader/assets/fonts/Rubik-Regular.ttf | Bin 0 -> 529936 bytes src/newsreader/conf/base.py | 3 + src/newsreader/fixtures/default-fixture.json | 215 ++++++++-- .../js/pages/homepage/components/PostModal.js | 34 +- .../homepage/components/feedlist/FeedList.js | 14 +- .../homepage/components/feedlist/PostItem.js | 35 +- .../homepage/components/feedlist/RuleItem.js | 24 -- .../homepage/components/feedlist/filters.js | 15 +- .../components/sidebar/CategoryItem.js | 2 +- .../homepage/components/sidebar/RuleItem.js | 4 +- .../news/collection/views/rules.html | 11 +- src/newsreader/news/core/endpoints.py | 2 +- .../templates/news/core/widgets/rule.html | 7 +- .../tests/endpoints/category/list/tests.py | 9 +- .../scss/components/body/_body.scss | 5 +- .../scss/components/body/index.scss | 2 +- .../scss/components/card/index.scss | 4 +- .../scss/components/category/_category.scss | 10 +- .../scss/components/category/index.scss | 2 +- .../scss/components/errorlist/index.scss | 2 +- .../scss/components/fieldset/index.scss | 2 +- .../scss/components/form/_form.scss | 2 +- .../scss/components/form/index.scss | 5 +- src/newsreader/scss/components/index.scss | 45 +-- .../scss/components/list/index.scss | 2 +- .../components/loading-indicator/index.scss | 2 +- .../scss/components/main/index.scss | 2 +- .../scss/components/messages/index.scss | 2 +- .../scss/components/modal/_modal.scss | 1 - .../scss/components/modal/index.scss | 5 +- .../scss/components/navbar/_navbar.scss | 20 +- .../scss/components/navbar/index.scss | 2 +- .../scss/components/pagination/index.scss | 2 +- .../components/post-block/_post-block.scss | 12 - .../scss/components/post-block/index.scss | 1 - .../post-message/_post-message.scss | 2 - .../scss/components/post-message/index.scss | 2 +- .../scss/components/post/_post.scss | 62 +-- .../scss/components/post/index.scss | 2 +- .../posts-header/_posts-header.scss | 15 - .../scss/components/posts-header/index.scss | 1 - .../components/posts-info/_posts-info.scss | 10 +- .../scss/components/posts-info/index.scss | 2 +- .../posts-section/_post-section.scss | 20 - .../scss/components/posts-section/index.scss | 1 - .../scss/components/posts/_posts.scss | 47 ++- .../scss/components/posts/index.scss | 2 +- .../scss/components/rules/_rules.scss | 6 +- .../scss/components/rules/index.scss | 2 +- .../scss/components/section/index.scss | 4 +- .../scss/components/sidebar/_sidebar.scss | 2 - .../scss/components/sidebar/index.scss | 2 +- .../scss/components/table/_table.scss | 2 - .../scss/components/table/index.scss | 4 +- .../scss/elements/badge/_badge.scss | 4 +- src/newsreader/scss/elements/badge/index.scss | 2 +- .../scss/elements/button/_button.scss | 6 +- .../scss/elements/button/index.scss | 4 +- .../scss/elements/checkbox/_checkbox.scss | 35 ++ .../scss/elements/checkbox/index.scss | 1 + src/newsreader/scss/elements/h1/_h1.scss | 7 +- src/newsreader/scss/elements/h1/index.scss | 2 +- src/newsreader/scss/elements/h2/_h2.scss | 6 +- src/newsreader/scss/elements/h2/index.scss | 2 +- src/newsreader/scss/elements/h3/_h3.scss | 6 +- src/newsreader/scss/elements/h3/index.scss | 2 +- src/newsreader/scss/elements/h4/_h4.scss | 7 + src/newsreader/scss/elements/h4/index.scss | 1 + src/newsreader/scss/elements/h5/_h5.scss | 7 + src/newsreader/scss/elements/h5/index.scss | 1 + .../scss/elements/help-text/index.scss | 2 +- src/newsreader/scss/elements/index.scss | 25 +- .../scss/elements/input/_input.scss | 2 - src/newsreader/scss/elements/input/index.scss | 2 +- src/newsreader/scss/elements/label/index.scss | 2 +- src/newsreader/scss/elements/link/index.scss | 2 +- .../scss/elements/select/index.scss | 2 +- src/newsreader/scss/elements/small/index.scss | 2 +- src/newsreader/scss/index.scss | 10 +- src/newsreader/scss/lib/_css.gg.scss | 3 +- src/newsreader/scss/lib/_mixins.scss | 4 +- src/newsreader/scss/lib/index.scss | 2 +- src/newsreader/scss/pages/index.scss | 20 +- src/newsreader/scss/partials/_colors.scss | 9 +- src/newsreader/scss/partials/_fonts.scss | 25 +- src/newsreader/scss/partials/index.scss | 4 +- .../templates/components/form/attrs.html | 18 + .../templates/components/form/checkbox.html | 10 + .../templates/components/form/input.html | 7 + .../templates/django/forms/widgets/attrs.html | 1 + .../django/forms/widgets/checkbox.html | 1 + src/newsreader/utils/__init__.py | 0 src/newsreader/utils/admin.py | 1 + src/newsreader/utils/apps.py | 5 + src/newsreader/utils/form.py | 16 + src/newsreader/utils/migrations/__init__.py | 0 src/newsreader/utils/models.py | 1 + src/newsreader/utils/templatetags/filters.py | 9 + src/newsreader/utils/views.py | 1 + webpack.common.babel.js | 10 + 112 files changed, 948 insertions(+), 510 deletions(-) create mode 100755 src/newsreader/assets/fonts/METADATA.pb create mode 100755 src/newsreader/assets/fonts/Rubik-Black.ttf create mode 100755 src/newsreader/assets/fonts/Rubik-BlackItalic.ttf create mode 100755 src/newsreader/assets/fonts/Rubik-Bold.ttf create mode 100755 src/newsreader/assets/fonts/Rubik-BoldItalic.ttf create mode 100755 src/newsreader/assets/fonts/Rubik-Italic.ttf create mode 100755 src/newsreader/assets/fonts/Rubik-Light.ttf create mode 100755 src/newsreader/assets/fonts/Rubik-LightItalic.ttf create mode 100755 src/newsreader/assets/fonts/Rubik-Medium.ttf create mode 100755 src/newsreader/assets/fonts/Rubik-MediumItalic.ttf create mode 100755 src/newsreader/assets/fonts/Rubik-Regular.ttf delete mode 100644 src/newsreader/js/pages/homepage/components/feedlist/RuleItem.js delete mode 100644 src/newsreader/scss/components/post-block/_post-block.scss delete mode 100644 src/newsreader/scss/components/post-block/index.scss delete mode 100644 src/newsreader/scss/components/posts-header/_posts-header.scss delete mode 100644 src/newsreader/scss/components/posts-header/index.scss delete mode 100644 src/newsreader/scss/components/posts-section/_post-section.scss delete mode 100644 src/newsreader/scss/components/posts-section/index.scss create mode 100644 src/newsreader/scss/elements/checkbox/_checkbox.scss create mode 100644 src/newsreader/scss/elements/checkbox/index.scss create mode 100644 src/newsreader/scss/elements/h4/_h4.scss create mode 100644 src/newsreader/scss/elements/h4/index.scss create mode 100644 src/newsreader/scss/elements/h5/_h5.scss create mode 100644 src/newsreader/scss/elements/h5/index.scss create mode 100644 src/newsreader/templates/components/form/attrs.html create mode 100644 src/newsreader/templates/components/form/checkbox.html create mode 100644 src/newsreader/templates/components/form/input.html create mode 120000 src/newsreader/templates/django/forms/widgets/attrs.html create mode 120000 src/newsreader/templates/django/forms/widgets/checkbox.html create mode 100644 src/newsreader/utils/__init__.py create mode 100644 src/newsreader/utils/admin.py create mode 100644 src/newsreader/utils/apps.py create mode 100644 src/newsreader/utils/form.py create mode 100644 src/newsreader/utils/migrations/__init__.py create mode 100644 src/newsreader/utils/models.py create mode 100644 src/newsreader/utils/templatetags/filters.py create mode 100644 src/newsreader/utils/views.py diff --git a/package-lock.json b/package-lock.json index 50f72a4..d884a42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2315,9 +2315,9 @@ "dev": true }, "camelcase": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz", - "integrity": "sha1-MvxLn82vhF/N9+c7uXysImHwqwo=", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", + "integrity": "sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=", "dev": true }, "camelcase-keys": { @@ -2328,14 +2328,6 @@ "requires": { "camelcase": "^2.0.0", "map-obj": "^1.0.0" - }, - "dependencies": { - "camelcase": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", - "integrity": "sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=", - "dev": true - } } }, "caniuse-lite": { @@ -2525,14 +2517,42 @@ } }, "cliui": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", - "integrity": "sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", "dev": true, "requires": { - "string-width": "^1.0.1", - "strip-ansi": "^3.0.1", - "wrap-ansi": "^2.0.0" + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } } }, "clone-deep": { @@ -3502,6 +3522,38 @@ "integrity": "sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw==", "dev": true }, + "file-loader": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.0.0.tgz", + "integrity": "sha512-/aMOAYEFXDdjG0wytpTL5YQLfZnnTmLNjn+AIrJ/6HVnTfDqLsVKUUwkDf4I4kgex36BvjuXEn/TX9B/1ESyqQ==", + "dev": true, + "requires": { + "loader-utils": "^2.0.0", + "schema-utils": "^2.6.5" + }, + "dependencies": { + "json5": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz", + "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + } + }, + "loader-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz", + "integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + } + } + } + }, "file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -4195,17 +4247,6 @@ "inherits": "~2.0.0", "mkdirp": ">=0.5 0", "rimraf": "2" - }, - "dependencies": { - "rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "dev": true, - "requires": { - "glob": "^7.1.3" - } - } } }, "function-bind": { @@ -4240,9 +4281,9 @@ } }, "get-caller-file": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", - "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true }, "get-stdin": { @@ -4603,6 +4644,15 @@ "integrity": "sha512-oDM0kUSNFC31ShNxHKUyfZKy8ZeXZBWMjMdZHKLOk13uvT27VTL/QzRGfRUcevJhpkZAvlhPYuXkF7eNWrtyxQ==", "dev": true }, + "indent-string": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz", + "integrity": "sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=", + "dev": true, + "requires": { + "repeating": "^2.0.0" + } + }, "indexes-of": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz", @@ -4651,12 +4701,6 @@ "loose-envify": "^1.0.0" } }, - "invert-kv": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", - "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=", - "dev": true - }, "is-accessor-descriptor": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", @@ -5172,8 +5216,7 @@ }, "yargs-parser": { "version": "13.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.1.tgz", - "integrity": "sha512-oVAVsHz6uFrg3XQheFII8ESO2ssAf9luWuAd6Wexsu4F3OtIW0o8IribPXYrD4WC24LWtPrJlGy87y5udK+dxQ==", + "resolved": "", "dev": true, "requires": { "camelcase": "^5.0.0", @@ -5587,9 +5630,9 @@ } }, "yargs-parser": { - "version": "13.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.1.tgz", - "integrity": "sha512-oVAVsHz6uFrg3XQheFII8ESO2ssAf9luWuAd6Wexsu4F3OtIW0o8IribPXYrD4WC24LWtPrJlGy87y5udK+dxQ==", + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", + "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", "dev": true, "requires": { "camelcase": "^5.0.0", @@ -5788,9 +5831,9 @@ }, "dependencies": { "acorn": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.3.tgz", - "integrity": "sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw==", + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.4.tgz", + "integrity": "sha512-1D++VG7BhrtvQpNbBzovKNc1FLGGEE/oGe7b9xJm/RFHMBeUaUGpluV9RLjZa47YFdPcDAenEYuq9pQPcMdLJg==", "dev": true } } @@ -5858,15 +5901,6 @@ "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", "dev": true }, - "lcid": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", - "integrity": "sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU=", - "dev": true, - "requires": { - "invert-kv": "^1.0.0" - } - }, "left-pad": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", @@ -6368,15 +6402,6 @@ "which": "1" }, "dependencies": { - "rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "dev": true, - "requires": { - "glob": "^7.1.3" - } - }, "semver": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz", @@ -6523,9 +6548,9 @@ } }, "node-sass": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/node-sass/-/node-sass-4.13.1.tgz", - "integrity": "sha512-TTWFx+ZhyDx1Biiez2nB0L3YrCZ/8oHagaDalbuBSlqXgUPsdkUSzJsVxeDO9LtPB49+Fh3WQl3slABo6AotNw==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/node-sass/-/node-sass-4.14.1.tgz", + "integrity": "sha512-sjCuOlvGyCJS40R8BscF5vhVlQjNN069NtQ1gSxyK1u9iqvn6tf7O1R4GNowVZfiZUCRt5MmMs1xd+4V/7Yr0g==", "dev": true, "requires": { "async-foreach": "^0.1.3", @@ -6542,7 +6567,7 @@ "node-gyp": "^3.8.0", "npmlog": "^4.0.0", "request": "^2.88.0", - "sass-graph": "^2.2.4", + "sass-graph": "2.2.5", "stdout-stream": "^1.4.0", "true-case-path": "^1.0.2" }, @@ -6778,15 +6803,6 @@ "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", "dev": true }, - "os-locale": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", - "integrity": "sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=", - "dev": true, - "requires": { - "lcid": "^1.0.0" - } - }, "os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", @@ -7455,17 +7471,6 @@ "requires": { "indent-string": "^2.1.0", "strip-indent": "^1.0.1" - }, - "dependencies": { - "indent-string": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz", - "integrity": "sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=", - "dev": true, - "requires": { - "repeating": "^2.0.0" - } - } } }, "redux": { @@ -7657,9 +7662,9 @@ "dev": true }, "require-main-filename": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz", - "integrity": "sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "dev": true }, "resolve": { @@ -7708,6 +7713,15 @@ "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", "dev": true }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, "ripemd160": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", @@ -7772,15 +7786,15 @@ } }, "sass-graph": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/sass-graph/-/sass-graph-2.2.4.tgz", - "integrity": "sha1-E/vWPNHK8JCLn9k0dq1DpR0eC0k=", + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/sass-graph/-/sass-graph-2.2.5.tgz", + "integrity": "sha512-VFWDAHOe6mRuT4mZRd4eKE+d8Uedrk6Xnh7Sh9b4NGufQLQjOrvf/MQoOdx+0s92L89FeyUUNfU597j/3uNpag==", "dev": true, "requires": { "glob": "^7.0.0", "lodash": "^4.0.0", "scss-tokenizer": "^0.2.3", - "yargs": "^7.0.0" + "yargs": "^13.3.2" } }, "sass-loader": { @@ -8878,6 +8892,54 @@ } } }, + "url-loader": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/url-loader/-/url-loader-4.1.0.tgz", + "integrity": "sha512-IzgAAIC8wRrg6NYkFIJY09vtktQcsvU8V6HhtQj9PTefbYImzLB1hufqo4m+RyM5N3mLx5BqJKccgxJS+W3kqw==", + "dev": true, + "requires": { + "loader-utils": "^2.0.0", + "mime-types": "^2.1.26", + "schema-utils": "^2.6.5" + }, + "dependencies": { + "json5": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz", + "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + } + }, + "loader-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz", + "integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + } + }, + "mime-db": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz", + "integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==", + "dev": true + }, + "mime-types": { + "version": "2.1.27", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz", + "integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==", + "dev": true, + "requires": { + "mime-db": "1.44.0" + } + } + } + }, "use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", @@ -9320,9 +9382,9 @@ } }, "which-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-1.0.0.tgz", - "integrity": "sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8=", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", "dev": true }, "wide-align": { @@ -9350,13 +9412,42 @@ } }, "wrap-ansi": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", - "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", "dev": true, "requires": { - "string-width": "^1.0.1", - "strip-ansi": "^3.0.1" + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } } }, "wrappy": { @@ -9398,9 +9489,9 @@ "dev": true }, "y18n": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz", - "integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE=", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", + "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", "dev": true }, "yallist": { @@ -9410,33 +9501,67 @@ "dev": true }, "yargs": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-7.1.0.tgz", - "integrity": "sha1-a6MY6xaWFyf10oT46gA+jWFU0Mg=", + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", + "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", "dev": true, "requires": { - "camelcase": "^3.0.0", - "cliui": "^3.2.0", - "decamelize": "^1.1.1", - "get-caller-file": "^1.0.1", - "os-locale": "^1.4.0", - "read-pkg-up": "^1.0.1", + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", - "require-main-filename": "^1.0.1", + "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", - "string-width": "^1.0.2", - "which-module": "^1.0.0", - "y18n": "^3.2.1", - "yargs-parser": "^5.0.0" + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.2" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } } }, "yargs-parser": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-5.0.0.tgz", - "integrity": "sha1-J17PDX/+Bcd+ZOfIbkzZS/DhIoo=", + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", + "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", "dev": true, "requires": { - "camelcase": "^3.0.0" + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "dependencies": { + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + } } } } diff --git a/package.json b/package.json index e2c4667..1fec809 100644 --- a/package.json +++ b/package.json @@ -44,16 +44,18 @@ "clean-webpack-plugin": "^3.0.0", "css-loader": "^3.4.2", "fetch-mock": "^8.3.1", + "file-loader": "^6.0.0", "jest": "^24.9.0", "mini-css-extract-plugin": "^0.9.0", "node-fetch": "^2.6.0", - "node-sass": "^4.13.1", + "node-sass": "^4.14.1", "prettier": "^1.19.1", "react": "^16.12.0", "react-dom": "^16.12.0", "redux-mock-store": "^1.5.4", "sass-loader": "^8.0.2", "style-loader": "^1.1.3", + "url-loader": "^4.1.0", "webpack": "^4.42.1", "webpack-cli": "^3.3.11", "webpack-merge": "^4.2.2" diff --git a/src/newsreader/assets/fonts/METADATA.pb b/src/newsreader/assets/fonts/METADATA.pb new file mode 100755 index 0000000..18857e1 --- /dev/null +++ b/src/newsreader/assets/fonts/METADATA.pb @@ -0,0 +1,101 @@ +name: "Rubik" +designer: "Hubert and Fischer, Meir Sadan, Cyreal" +license: "OFL" +category: "SANS_SERIF" +date_added: "2015-07-22" +fonts { + name: "Rubik" + style: "normal" + weight: 300 + filename: "Rubik-Light.ttf" + post_script_name: "Rubik-Light" + full_name: "Rubik Light" + copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)" +} +fonts { + name: "Rubik" + style: "italic" + weight: 300 + filename: "Rubik-LightItalic.ttf" + post_script_name: "Rubik-LightItalic" + full_name: "Rubik Light Italic" + copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)" +} +fonts { + name: "Rubik" + style: "normal" + weight: 400 + filename: "Rubik-Regular.ttf" + post_script_name: "Rubik-Regular" + full_name: "Rubik Regular" + copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)" +} +fonts { + name: "Rubik" + style: "italic" + weight: 400 + filename: "Rubik-Italic.ttf" + post_script_name: "Rubik-Italic" + full_name: "Rubik Italic" + copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)" +} +fonts { + name: "Rubik" + style: "normal" + weight: 500 + filename: "Rubik-Medium.ttf" + post_script_name: "Rubik-Medium" + full_name: "Rubik Medium" + copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)" +} +fonts { + name: "Rubik" + style: "italic" + weight: 500 + filename: "Rubik-MediumItalic.ttf" + post_script_name: "Rubik-MediumItalic" + full_name: "Rubik Medium Italic" + copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)" +} +fonts { + name: "Rubik" + style: "normal" + weight: 700 + filename: "Rubik-Bold.ttf" + post_script_name: "Rubik-Bold" + full_name: "Rubik Bold" + copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)" +} +fonts { + name: "Rubik" + style: "italic" + weight: 700 + filename: "Rubik-BoldItalic.ttf" + post_script_name: "Rubik-BoldItalic" + full_name: "Rubik Bold Italic" + copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)" +} +fonts { + name: "Rubik" + style: "normal" + weight: 900 + filename: "Rubik-Black.ttf" + post_script_name: "Rubik-Black" + full_name: "Rubik Black" + copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)" +} +fonts { + name: "Rubik" + style: "italic" + weight: 900 + filename: "Rubik-BlackItalic.ttf" + post_script_name: "Rubik-BlackItalic" + full_name: "Rubik Black Italic" + copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)" +} +subsets: "cyrillic" +subsets: "cyrillic-ext" +subsets: "hebrew" +subsets: "latin" +subsets: "latin-ext" +subsets: "menu" diff --git a/src/newsreader/assets/fonts/Rubik-Black.ttf b/src/newsreader/assets/fonts/Rubik-Black.ttf new file mode 100755 index 0000000000000000000000000000000000000000..0ffcec92d692b4518ce10ef0497a9b1e295dd0f2 GIT binary patch literal 510760 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

      oN!(H|{F- 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

      0=2ZRcT5ZA2>XWO_{Qvs)^QDfy#TF&petm=& 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�KuFyn?956yUd#&aDh9r+!Xb-Xh(W#-(O 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�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

      *a!$lp+U3#p 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 z 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 literal 0 HcmV?d00001 diff --git a/src/newsreader/assets/fonts/Rubik-BoldItalic.ttf b/src/newsreader/assets/fonts/Rubik-BoldItalic.ttf new file mode 100755 index 0000000000000000000000000000000000000000..d380dac47325060937191af9ed927b36d377203f GIT binary patch literal 574128 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

      VKsH|X5^^c#|EV)PBlf@t2*KyOsw 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 V5kNEC*gT1qKKbO6-{b!${}0*o9=QMj literal 0 HcmV?d00001 diff --git a/src/newsreader/assets/fonts/Rubik-Italic.ttf b/src/newsreader/assets/fonts/Rubik-Italic.ttf new file mode 100755 index 0000000000000000000000000000000000000000..cf43a4bfc2cf64b080bd8f714c723f3d8d2f1836 GIT binary patch literal 582340 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

      AKpX&FEOkgI>ZAc4C;mye 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̳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⋘eam1q6 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 LrT_i5{_p<)aED&H literal 0 HcmV?d00001 diff --git a/src/newsreader/assets/fonts/Rubik-Light.ttf b/src/newsreader/assets/fonts/Rubik-Light.ttf new file mode 100755 index 0000000000000000000000000000000000000000..f6e44cc110c42e45b1aff5c25813336bfe852cea GIT binary patch literal 523812 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�%VfLh@3_gO% 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 Is`G!{%S{Agc63Xr&baNIV7YsGEFRVRs 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%0Dm7r!qP=o4#n$?A*(OYPeSS;GNUtsnC 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 z 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

      TB6fNzw{FP>~1UBhhj>k%22B1D5}gn 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
      - + - {/* HTML is sanitized by the collectors */}
      diff --git a/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js b/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js index e873965..e679eed 100644 --- a/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js +++ b/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js @@ -6,7 +6,7 @@ import { fetchPostsBySection } from '../../actions/posts.js'; import { filterPosts } from './filters.js'; import LoadingIndicator from '../../../../components/LoadingIndicator.js'; -import RuleItem from './RuleItem.js'; +import PostItem from './PostItem.js'; class FeedList extends React.Component { checkScrollHeight = ::this.checkScrollHeight; @@ -37,8 +37,8 @@ class FeedList extends React.Component { } render() { - const ruleItems = this.props.posts.map((item, index) => { - return ; + const postItems = this.props.postsBySection.map((item, index) => { + return ; }); if (isEqual(this.props.selected, {})) { @@ -50,7 +50,7 @@ class FeedList extends React.Component {
      ); - } else if (ruleItems.length === 0 && !this.props.isFetching) { + } else if (postItems.length === 0 && !this.props.isFetching) { return (
      @@ -62,8 +62,8 @@ class FeedList extends React.Component { ); } else { return ( -
      - {ruleItems} +
      +
        {postItems}
      {this.props.isFetching && }
      ); @@ -73,7 +73,7 @@ class FeedList extends React.Component { const mapStateToProps = state => ({ isFetching: state.posts.isFetching, - posts: filterPosts(state), + postsBySection: filterPosts(state), next: state.selected.next, lastReached: state.selected.lastReached, selected: state.selected.item, diff --git a/src/newsreader/js/pages/homepage/components/feedlist/PostItem.js b/src/newsreader/js/pages/homepage/components/feedlist/PostItem.js index a24b9c0..a796916 100644 --- a/src/newsreader/js/pages/homepage/components/feedlist/PostItem.js +++ b/src/newsreader/js/pages/homepage/components/feedlist/PostItem.js @@ -1,33 +1,44 @@ import React from 'react'; import { connect } from 'react-redux'; +import { CATEGORY_TYPE, RULE_TYPE } from '../../constants.js'; import { selectPost } from '../../actions/posts.js'; - import { formatDatetime } from '../../../../utils.js'; class PostItem extends React.Component { render() { - const post = this.props.post; + const rule = { ...this.props.post.rule }; + const post = { ...this.props.post, rule: rule.id }; const publicationDate = formatDatetime(post.publicationDate); const titleClassName = post.read - ? 'posts-header__title posts-header__title--read' - : 'posts-header__title'; + ? 'posts__header posts__header--read' + : 'posts__header'; return ( -
    • { - this.props.selectPost(post); - }} - > -
      +
    • +
      this.props.selectPost(post)} + > {post.title}
      - {publicationDate} + {publicationDate} {post.author && `By ${post.author}`} + {this.props.selected.type == CATEGORY_TYPE && ( + + + {rule.name} + + + )} { - return new Date(secondEl.publicationDate) - new Date(firstEl.publicationDate); - }); - - const postItems = posts.map(post => { - return ; - }); - - return ( -
      -

      {this.props.rule.name}

      -
        {postItems}
      -
      - ); - } -} - -export default RuleItem; diff --git a/src/newsreader/js/pages/homepage/components/feedlist/filters.js b/src/newsreader/js/pages/homepage/components/feedlist/filters.js index a3ee886..59fd665 100644 --- a/src/newsreader/js/pages/homepage/components/feedlist/filters.js +++ b/src/newsreader/js/pages/homepage/components/feedlist/filters.js @@ -9,7 +9,9 @@ export const filterPostsByRule = (rule = {}, posts = []) => { return post.rule === rule.id; }); - return filteredPosts.length > 0 ? [{ rule, posts: filteredPosts }] : []; + const filteredData = filteredPosts.map(post => ({ ...post, rule: { ...rule } })); + + return filteredData.length > 0 ? [...filteredData] : []; }; export const filterPostsByCategory = (category = {}, rules = [], posts = []) => { @@ -22,13 +24,14 @@ export const filterPostsByCategory = (category = {}, rules = [], posts = []) => return post.rule === rule.id; }); - return { - rule: { ...rule }, - posts: filteredPosts, - }; + return filteredPosts.map(post => ({ ...post, rule: { ...rule } })); }); - return filteredData.filter(rule => rule.posts.length > 0); + const sortedPosts = [...filteredData.flat()].sort((firstPost, secondPost) => { + return new Date(secondPost.publicationDate) - new Date(firstPost.publicationDate); + }); + + return sortedPosts; }; export const filterPosts = state => { diff --git a/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js b/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js index d1a0c94..7c0268c 100644 --- a/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js +++ b/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js @@ -40,7 +40,7 @@ class CategoryItem extends React.Component {
      this.handleSelect()}> -

      {this.props.category.name}

      + {this.props.category.name} {this.props.category.unread}
    • diff --git a/src/newsreader/js/pages/homepage/components/sidebar/RuleItem.js b/src/newsreader/js/pages/homepage/components/sidebar/RuleItem.js index 879745f..df8da94 100644 --- a/src/newsreader/js/pages/homepage/components/sidebar/RuleItem.js +++ b/src/newsreader/js/pages/homepage/components/sidebar/RuleItem.js @@ -31,9 +31,9 @@ class RuleItem extends React.Component {
    • this.handleSelect()}>
      {favicon} -
      + {this.props.rule.name} -
      +
      {this.props.rule.unread}
    • diff --git a/src/newsreader/news/collection/templates/news/collection/views/rules.html b/src/newsreader/news/collection/templates/news/collection/views/rules.html index a17b818..1da7c4d 100644 --- a/src/newsreader/news/collection/templates/news/collection/views/rules.html +++ b/src/newsreader/news/collection/templates/news/collection/views/rules.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% load i18n static %} +{% load i18n static filters %} {% block content %}
      @@ -15,6 +15,7 @@ @@ -23,7 +24,7 @@ - + {% include "components/form/checkbox.html" with id="select-all" data_input="rules" id_for_label="select-all" %} {% trans "Name" %} {% trans "Category" %} @@ -36,7 +37,11 @@ {% for rule in rules %} - + + {% with rule|id_for_label:"rules" as id_for_label %} + {% include "components/form/checkbox.html" with name="rules" value=rule.pk id=id_for_label id_for_label=id_for_label %} + {% endwith %} + {{ rule.name }} {{ rule.category.name }} {{ rule.url }} diff --git a/src/newsreader/news/core/endpoints.py b/src/newsreader/news/core/endpoints.py index acc4ab2..f5a48bc 100644 --- a/src/newsreader/news/core/endpoints.py +++ b/src/newsreader/news/core/endpoints.py @@ -92,7 +92,7 @@ class NestedPostCategoryView(ListAPIView): queryset = Post.objects.filter( rule__in=category.rules.values_list("id", flat=True) - ).order_by("rule", "-publication_date") + ).order_by("-publication_date", "rule__name") return queryset diff --git a/src/newsreader/news/core/templates/news/core/widgets/rule.html b/src/newsreader/news/core/templates/news/core/widgets/rule.html index b3c7b68..c8535e8 100644 --- a/src/newsreader/news/core/templates/news/core/widgets/rule.html +++ b/src/newsreader/news/core/templates/news/core/widgets/rule.html @@ -1,5 +1,8 @@ - +{% load filters %} + +{% with option.instance|id_for_label:"category" as id_for_label %} + {% include "components/form/checkbox.html" with widget=option checked=option.selected id_for_label=id_for_label only %} +{% endwith %} {% if option.instance.favicon %} diff --git a/src/newsreader/news/core/tests/endpoints/category/list/tests.py b/src/newsreader/news/core/tests/endpoints/category/list/tests.py index aedd5e1..4d5f0e6 100644 --- a/src/newsreader/news/core/tests/endpoints/category/list/tests.py +++ b/src/newsreader/news/core/tests/endpoints/category/list/tests.py @@ -498,13 +498,12 @@ class NestedCategoryPostView(TestCase): self.assertEquals(data["count"], 6) self.assertEquals(posts[0]["title"], "Second BBC post") - self.assertEquals(posts[1]["title"], "First BBC post") - + self.assertEquals(posts[1]["title"], "Second Reuters post") self.assertEquals(posts[2]["title"], "Second Guardian post") - self.assertEquals(posts[3]["title"], "First Guardian post") - self.assertEquals(posts[4]["title"], "Second Reuters post") - self.assertEquals(posts[5]["title"], "First Reuters post") + self.assertEquals(posts[3]["title"], "First BBC post") + self.assertEquals(posts[4]["title"], "First Reuters post") + self.assertEquals(posts[5]["title"], "First Guardian post") def test_only_posts_from_category_are_returned(self): category = CategoryFactory.create(user=self.user) diff --git a/src/newsreader/scss/components/body/_body.scss b/src/newsreader/scss/components/body/_body.scss index 306ad7c..fed260b 100644 --- a/src/newsreader/scss/components/body/_body.scss +++ b/src/newsreader/scss/components/body/_body.scss @@ -1,10 +1,9 @@ .body { margin: 0; padding: 0; - background-color: $gainsboro; - font-family: $default-font; - color: $default-font-color; + font-family: Rubik, sans-serif; + color: $font-color; } body { diff --git a/src/newsreader/scss/components/body/index.scss b/src/newsreader/scss/components/body/index.scss index 533e39e..62b72d4 100644 --- a/src/newsreader/scss/components/body/index.scss +++ b/src/newsreader/scss/components/body/index.scss @@ -1 +1 @@ -@import "body"; +@import './body'; diff --git a/src/newsreader/scss/components/card/index.scss b/src/newsreader/scss/components/card/index.scss index 149efa0..8c358b8 100644 --- a/src/newsreader/scss/components/card/index.scss +++ b/src/newsreader/scss/components/card/index.scss @@ -1,2 +1,2 @@ -@import "card"; -@import "rule-card"; +@import './card'; +@import './rule-card'; diff --git a/src/newsreader/scss/components/category/_category.scss b/src/newsreader/scss/components/category/_category.scss index 9d8451f..683ed0b 100644 --- a/src/newsreader/scss/components/category/_category.scss +++ b/src/newsreader/scss/components/category/_category.scss @@ -4,8 +4,6 @@ padding: 5px; - border-radius: 5px; - &__info { display: flex; justify-content: space-between; @@ -35,11 +33,7 @@ } } - &:hover { - background-color: darken($azureish-white, +10%); - } - - &--selected { - background-color: darken($azureish-white, +10%); + &--selected, &:hover { + background-color: $border-gray; } } diff --git a/src/newsreader/scss/components/category/index.scss b/src/newsreader/scss/components/category/index.scss index 702bb58..d434e4f 100644 --- a/src/newsreader/scss/components/category/index.scss +++ b/src/newsreader/scss/components/category/index.scss @@ -1 +1 @@ -@import "category"; +@import './category'; diff --git a/src/newsreader/scss/components/errorlist/index.scss b/src/newsreader/scss/components/errorlist/index.scss index bf0c9c9..0987360 100644 --- a/src/newsreader/scss/components/errorlist/index.scss +++ b/src/newsreader/scss/components/errorlist/index.scss @@ -1 +1 @@ -@import "errorlist"; +@import './errorlist'; diff --git a/src/newsreader/scss/components/fieldset/index.scss b/src/newsreader/scss/components/fieldset/index.scss index be990a8..069cfdb 100644 --- a/src/newsreader/scss/components/fieldset/index.scss +++ b/src/newsreader/scss/components/fieldset/index.scss @@ -1 +1 @@ -@import "fieldset"; +@import './fieldset'; diff --git a/src/newsreader/scss/components/form/_form.scss b/src/newsreader/scss/components/form/_form.scss index 19e9d4b..79d3e43 100644 --- a/src/newsreader/scss/components/form/_form.scss +++ b/src/newsreader/scss/components/form/_form.scss @@ -7,7 +7,6 @@ width: 70%; border-radius: 5px; - font-family: $form-font; background-color: $white; &__section { @@ -47,6 +46,7 @@ &__actions { display: flex; flex-direction: row; + gap: 15px; @include form-padding; } diff --git a/src/newsreader/scss/components/form/index.scss b/src/newsreader/scss/components/form/index.scss index 8069223..fc89867 100644 --- a/src/newsreader/scss/components/form/index.scss +++ b/src/newsreader/scss/components/form/index.scss @@ -1,3 +1,2 @@ -@import "form"; - -@import "rules-form"; +@import './form'; +@import './rules-form'; diff --git a/src/newsreader/scss/components/index.scss b/src/newsreader/scss/components/index.scss index 53e0f71..cc9e717 100644 --- a/src/newsreader/scss/components/index.scss +++ b/src/newsreader/scss/components/index.scss @@ -1,28 +1,25 @@ -@import "body/index"; -@import "form/index"; -@import "main/index"; -@import "navbar/index"; -@import "loading-indicator/index"; +@import './body/index'; +@import './form/index'; +@import './main/index'; +@import './navbar/index'; +@import './loading-indicator/index'; -@import "modal/index"; +@import './modal/index'; -@import "card/index"; -@import "list/index"; -@import "messages/index"; -@import "section/index"; -@import "errorlist/index"; -@import "fieldset/index"; -@import "pagination/index"; -@import "sidebar/index"; -@import "table/index"; +@import './card/index'; +@import './list/index'; +@import './messages/index'; +@import './section/index'; +@import './errorlist/index'; +@import './fieldset/index'; +@import './pagination/index'; +@import './sidebar/index'; +@import './table/index'; -@import "rules/index"; -@import "category/index"; +@import './rules/index'; +@import './category/index'; -@import "post/index"; -@import "post-block/index"; -@import "post-message/index"; -@import "posts/index"; -@import "posts-header/index"; -@import "posts-info/index"; -@import "posts-section/index"; +@import './post/index'; +@import './post-message/index'; +@import './posts/index'; +@import './posts-info/index'; diff --git a/src/newsreader/scss/components/list/index.scss b/src/newsreader/scss/components/list/index.scss index 0a92e49..8c42751 100644 --- a/src/newsreader/scss/components/list/index.scss +++ b/src/newsreader/scss/components/list/index.scss @@ -1 +1 @@ -@import "list"; +@import './list'; diff --git a/src/newsreader/scss/components/loading-indicator/index.scss b/src/newsreader/scss/components/loading-indicator/index.scss index c3a3bc3..fc2ebd7 100644 --- a/src/newsreader/scss/components/loading-indicator/index.scss +++ b/src/newsreader/scss/components/loading-indicator/index.scss @@ -1 +1 @@ -@import "loading-indicator"; +@import './loading-indicator'; diff --git a/src/newsreader/scss/components/main/index.scss b/src/newsreader/scss/components/main/index.scss index bdb4ce0..dd76080 100644 --- a/src/newsreader/scss/components/main/index.scss +++ b/src/newsreader/scss/components/main/index.scss @@ -1 +1 @@ -@import "main"; +@import './main'; diff --git a/src/newsreader/scss/components/messages/index.scss b/src/newsreader/scss/components/messages/index.scss index 1e28703..890a555 100644 --- a/src/newsreader/scss/components/messages/index.scss +++ b/src/newsreader/scss/components/messages/index.scss @@ -1 +1 @@ -@import "messages"; +@import './messages'; diff --git a/src/newsreader/scss/components/modal/_modal.scss b/src/newsreader/scss/components/modal/_modal.scss index 93fe54f..3ca246c 100644 --- a/src/newsreader/scss/components/modal/_modal.scss +++ b/src/newsreader/scss/components/modal/_modal.scss @@ -20,7 +20,6 @@ width: 60%; - border-radius: 5px; background-color: $white; } diff --git a/src/newsreader/scss/components/modal/index.scss b/src/newsreader/scss/components/modal/index.scss index d84836a..e239d86 100644 --- a/src/newsreader/scss/components/modal/index.scss +++ b/src/newsreader/scss/components/modal/index.scss @@ -1,3 +1,2 @@ -@import "modal"; - -@import "post-modal"; +@import './modal'; +@import './post-modal'; diff --git a/src/newsreader/scss/components/navbar/_navbar.scss b/src/newsreader/scss/components/navbar/_navbar.scss index 0176265..60c9f48 100644 --- a/src/newsreader/scss/components/navbar/_navbar.scss +++ b/src/newsreader/scss/components/navbar/_navbar.scss @@ -6,8 +6,6 @@ padding: 10px 0; width: 100%; - background-color: $white; - ol { display: flex; justify-content: flex-start; @@ -16,26 +14,14 @@ list-style-type: none; } - a { - color: $nickel; - text-decoration: none; - } - &__item { margin: 0px 10px; - border: none; - border-radius: 2px; - - background-color: darken($azureish-white, 20%); - - &:hover{ - background-color: lighten($azureish-white, +5%); - } - & a { @extend .button; - color: $white; + + font-size: 18px !important; + font-weight: 600; } } diff --git a/src/newsreader/scss/components/navbar/index.scss b/src/newsreader/scss/components/navbar/index.scss index b45a5a0..dd4098f 100644 --- a/src/newsreader/scss/components/navbar/index.scss +++ b/src/newsreader/scss/components/navbar/index.scss @@ -1 +1 @@ -@import "navbar"; +@import './navbar'; diff --git a/src/newsreader/scss/components/pagination/index.scss b/src/newsreader/scss/components/pagination/index.scss index d92e61f..185e83e 100644 --- a/src/newsreader/scss/components/pagination/index.scss +++ b/src/newsreader/scss/components/pagination/index.scss @@ -1 +1 @@ -@import "pagination"; +@import './pagination'; diff --git a/src/newsreader/scss/components/post-block/_post-block.scss b/src/newsreader/scss/components/post-block/_post-block.scss deleted file mode 100644 index c65352b..0000000 --- a/src/newsreader/scss/components/post-block/_post-block.scss +++ /dev/null @@ -1,12 +0,0 @@ -.post-block { - display: flex; - flex-direction: column; - align-items: center; - - width: 70%; - margin: 0 0 2% 0; - - font-family: $article-font; - - border-radius: 2px; -} diff --git a/src/newsreader/scss/components/post-block/index.scss b/src/newsreader/scss/components/post-block/index.scss deleted file mode 100644 index e17b7a9..0000000 --- a/src/newsreader/scss/components/post-block/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "post-block"; diff --git a/src/newsreader/scss/components/post-message/_post-message.scss b/src/newsreader/scss/components/post-message/_post-message.scss index 03a1dc2..574d633 100644 --- a/src/newsreader/scss/components/post-message/_post-message.scss +++ b/src/newsreader/scss/components/post-message/_post-message.scss @@ -6,9 +6,7 @@ width: 60%; height: 80vh; - border-radius: 2px; - font-family: $article-font; background-color: $white; &__message { diff --git a/src/newsreader/scss/components/post-message/index.scss b/src/newsreader/scss/components/post-message/index.scss index 03cf130..7114cb2 100644 --- a/src/newsreader/scss/components/post-message/index.scss +++ b/src/newsreader/scss/components/post-message/index.scss @@ -1 +1 @@ -@import "post-message"; +@import './post-message'; diff --git a/src/newsreader/scss/components/post/_post.scss b/src/newsreader/scss/components/post/_post.scss index a7b5ed5..46d389d 100644 --- a/src/newsreader/scss/components/post/_post.scss +++ b/src/newsreader/scss/components/post/_post.scss @@ -9,8 +9,6 @@ margin: 2% auto 5% auto; - border-radius: 5px; - overflow-y: auto; background-color: $white; @@ -22,13 +20,9 @@ flex-direction: column; padding: 20px 0 10px 0; width: 75%; - - font-family: $article-header-font; } &__title { - line-height: 1; - &--read { color: $gainsboro; } @@ -44,10 +38,21 @@ } &__date { - align-self: center; font-size: small; } + &__author { + font-size: small; + } + + &__rule, &__category { + background-color: $light-orange !important; + + & a { + color: $black; + } + } + &__body { display:flex; flex-direction: column; @@ -55,10 +60,6 @@ padding: 10px 0 30px 0; width: 75%; - line-height: 1.5; - font-family: $article-font; - font-size: 18px; - & p { padding: 10px 0; } @@ -82,9 +83,11 @@ position: relative; margin: 1% 2% 0 0; align-self: flex-end; + background-color: $button-blue; + color: $white; &:hover { - background-color: lighten($gainsboro, +1%); + background-color: lighten($button-blue, +1%); } } @@ -94,39 +97,6 @@ align-items: center; margin: 15px 0; - } - - &__section-info { - display: flex; - flex-direction: column; - - align-self: flex-end; - position: absolute; - top: 25%; - width: 10%; - - font-family: $button-font; - color: lighten($default-font-color, +10%); - - & h5 { - margin: 10px 0 0 0; - padding: 5px 1px 5px 5px; - - border-radius: 5px 0 0 5px; - - background-color: aquamarine; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - text-align: center; - } - - & h5:first-child { - background-color: $light-orange; - } - - & h5:last-child { - background-color: $beige; - } + gap: 5px; } } diff --git a/src/newsreader/scss/components/post/index.scss b/src/newsreader/scss/components/post/index.scss index b31e7bb..0ad3ea2 100644 --- a/src/newsreader/scss/components/post/index.scss +++ b/src/newsreader/scss/components/post/index.scss @@ -1 +1 @@ -@import "post"; +@import './post'; diff --git a/src/newsreader/scss/components/posts-header/_posts-header.scss b/src/newsreader/scss/components/posts-header/_posts-header.scss deleted file mode 100644 index be0dac8..0000000 --- a/src/newsreader/scss/components/posts-header/_posts-header.scss +++ /dev/null @@ -1,15 +0,0 @@ -.posts-header { - - &__title { - width: 80%; - - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 16px; - - &--read { - color: darken($gainsboro, +10%); - } - } -} diff --git a/src/newsreader/scss/components/posts-header/index.scss b/src/newsreader/scss/components/posts-header/index.scss deleted file mode 100644 index 451a453..0000000 --- a/src/newsreader/scss/components/posts-header/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "posts-header"; diff --git a/src/newsreader/scss/components/posts-info/_posts-info.scss b/src/newsreader/scss/components/posts-info/_posts-info.scss index 9f9bc6c..c199961 100644 --- a/src/newsreader/scss/components/posts-info/_posts-info.scss +++ b/src/newsreader/scss/components/posts-info/_posts-info.scss @@ -1,9 +1,9 @@ .posts-info { display: flex; - justify-content: space-around; align-items: center; - width: 20%; + margin: 10px 0; + gap: 15px; &__date { align-self: center; @@ -12,4 +12,10 @@ &__link { display: inline-flex; } + + & .badge { + & a { + color: $black; + } + } } diff --git a/src/newsreader/scss/components/posts-info/index.scss b/src/newsreader/scss/components/posts-info/index.scss index 2a7a495..50a159b 100644 --- a/src/newsreader/scss/components/posts-info/index.scss +++ b/src/newsreader/scss/components/posts-info/index.scss @@ -1 +1 @@ -@import "posts-info"; +@import './posts-info'; diff --git a/src/newsreader/scss/components/posts-section/_post-section.scss b/src/newsreader/scss/components/posts-section/_post-section.scss deleted file mode 100644 index 1c40bec..0000000 --- a/src/newsreader/scss/components/posts-section/_post-section.scss +++ /dev/null @@ -1,20 +0,0 @@ -.posts-section { - display: flex; - flex-direction: column; - width: 95%; - - margin: 20px; - padding: 10px; - border-radius: 5px; - - background-color: $white; - - &:first-child { - padding: 0 10px 10px 10px; - margin: 0 20px 20px 20px; - } - - &__name { - padding: 20px 0 10px 0; - } -} diff --git a/src/newsreader/scss/components/posts-section/index.scss b/src/newsreader/scss/components/posts-section/index.scss deleted file mode 100644 index 945ed28..0000000 --- a/src/newsreader/scss/components/posts-section/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import "post-section"; diff --git a/src/newsreader/scss/components/posts/_posts.scss b/src/newsreader/scss/components/posts/_posts.scss index 9a3525b..94223a6 100644 --- a/src/newsreader/scss/components/posts/_posts.scss +++ b/src/newsreader/scss/components/posts/_posts.scss @@ -1,23 +1,25 @@ .posts { - display: flex; - flex-direction: column; + width: 70%; + margin: 0 0 2% 20px; - list-style: none; + &__list { + display: flex; + flex-direction: column; - padding: 0; + list-style: none; + + width: 95%; + padding: 0; + } &__item { display: flex; - align-items: center; + flex-direction: column-reverse; - padding: 10px 0 10px 0; + padding: 10px; - border-radius: 2px; - border-bottom: 2px solid $azureish-white; - - &:hover { - cursor: pointer; - background-color: $gainsboro; + &:first-child { + padding: 0 10px 10px 10px; } & span { @@ -27,8 +29,29 @@ white-space: nowrap; } + & .badge { + background-color: $light-orange; + } + &:last-child { border-bottom: 0; } } + + &__header { + width: 80%; + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 16px; + + &--read { + color: darken($gainsboro, +10%); + } + + &:hover { + cursor: pointer; + } + } } diff --git a/src/newsreader/scss/components/posts/index.scss b/src/newsreader/scss/components/posts/index.scss index 66f1811..b19500b 100644 --- a/src/newsreader/scss/components/posts/index.scss +++ b/src/newsreader/scss/components/posts/index.scss @@ -1 +1 @@ -@import "posts"; +@import './posts'; diff --git a/src/newsreader/scss/components/rules/_rules.scss b/src/newsreader/scss/components/rules/_rules.scss index 92427da..b07d03d 100644 --- a/src/newsreader/scss/components/rules/_rules.scss +++ b/src/newsreader/scss/components/rules/_rules.scss @@ -8,19 +8,17 @@ padding: 5px 5px 5px 20px; - border-radius: 5px; - & * { padding: 0 2px 0 2px; } &:hover { cursor: pointer; - background-color: $focus-blue; + background-color: $border-gray; } &--selected { - background-color: $focus-blue; + background-color: $border-gray; } } diff --git a/src/newsreader/scss/components/rules/index.scss b/src/newsreader/scss/components/rules/index.scss index e6a0ebf..1a0309e 100644 --- a/src/newsreader/scss/components/rules/index.scss +++ b/src/newsreader/scss/components/rules/index.scss @@ -1 +1 @@ -@import "rules"; +@import './rules'; diff --git a/src/newsreader/scss/components/section/index.scss b/src/newsreader/scss/components/section/index.scss index 0e02686..0721e8d 100644 --- a/src/newsreader/scss/components/section/index.scss +++ b/src/newsreader/scss/components/section/index.scss @@ -1,2 +1,2 @@ -@import "section"; -@import "text-section"; +@import './section'; +@import './text-section'; diff --git a/src/newsreader/scss/components/sidebar/_sidebar.scss b/src/newsreader/scss/components/sidebar/_sidebar.scss index feac44d..89df180 100644 --- a/src/newsreader/scss/components/sidebar/_sidebar.scss +++ b/src/newsreader/scss/components/sidebar/_sidebar.scss @@ -17,8 +17,6 @@ list-style: none; border-radius: 5px; - font-family: $sidebar-font; - &__item { padding: 2px 10px 5px 10px; } diff --git a/src/newsreader/scss/components/sidebar/index.scss b/src/newsreader/scss/components/sidebar/index.scss index 0abffa8..5e4494d 100644 --- a/src/newsreader/scss/components/sidebar/index.scss +++ b/src/newsreader/scss/components/sidebar/index.scss @@ -1 +1 @@ -@import "sidebar"; +@import './sidebar'; diff --git a/src/newsreader/scss/components/table/_table.scss b/src/newsreader/scss/components/table/_table.scss index 60ab7e8..01f81a0 100644 --- a/src/newsreader/scss/components/table/_table.scss +++ b/src/newsreader/scss/components/table/_table.scss @@ -1,8 +1,6 @@ @import "../../lib/mixins"; .table { - @include rounded; - table-layout: fixed; background-color: $white; width: 90%; diff --git a/src/newsreader/scss/components/table/index.scss b/src/newsreader/scss/components/table/index.scss index d175a21..d5f640e 100644 --- a/src/newsreader/scss/components/table/index.scss +++ b/src/newsreader/scss/components/table/index.scss @@ -1,2 +1,2 @@ -@import "table"; -@import "rules-table"; +@import './table'; +@import './rules-table'; diff --git a/src/newsreader/scss/elements/badge/_badge.scss b/src/newsreader/scss/elements/badge/_badge.scss index 1e2db24..efdd1b7 100644 --- a/src/newsreader/scss/elements/badge/_badge.scss +++ b/src/newsreader/scss/elements/badge/_badge.scss @@ -3,12 +3,10 @@ padding-left: 8px; padding-right: 8px; - border-radius: 2px; text-align: center; - background-color: lighten($pewter-blue, +20%); + background-color: $gainsboro; - font-family: $button-font; font-size: small; } diff --git a/src/newsreader/scss/elements/badge/index.scss b/src/newsreader/scss/elements/badge/index.scss index 87110f0..af0fda4 100644 --- a/src/newsreader/scss/elements/badge/index.scss +++ b/src/newsreader/scss/elements/badge/index.scss @@ -1 +1 @@ -@import "badge"; +@import './badge'; diff --git a/src/newsreader/scss/elements/button/_button.scss b/src/newsreader/scss/elements/button/_button.scss index 3a06cd3..2e97d6b 100644 --- a/src/newsreader/scss/elements/button/_button.scss +++ b/src/newsreader/scss/elements/button/_button.scss @@ -9,9 +9,7 @@ @include button-padding; border: none; - border-radius: 2px; - font-family: $button-font; font-size: 16px; &:hover { @@ -39,10 +37,10 @@ &--primary { color: $white !important; - background-color: darken($azureish-white, +20%); + background-color: $button-blue; &:hover { - background-color: $azureish-white; + background-color: lighten($button-blue, 5%); } } } diff --git a/src/newsreader/scss/elements/button/index.scss b/src/newsreader/scss/elements/button/index.scss index a9b2ec7..af09e59 100644 --- a/src/newsreader/scss/elements/button/index.scss +++ b/src/newsreader/scss/elements/button/index.scss @@ -1,2 +1,2 @@ -@import "button"; -@import "_read-button"; +@import './button'; +@import './read-button'; diff --git a/src/newsreader/scss/elements/checkbox/_checkbox.scss b/src/newsreader/scss/elements/checkbox/_checkbox.scss new file mode 100644 index 0000000..f23a7c3 --- /dev/null +++ b/src/newsreader/scss/elements/checkbox/_checkbox.scss @@ -0,0 +1,35 @@ +.checkbox { + display: block; + height: 20px; + width: 20px; + margin: 0 0 0 20px; + + + & input[type=checkbox] { + position: absolute; + opacity: 0; + + appearance: none; + -moz-appearance: none; + + &:checked + .checkbox__label { + .checkbox__box { + background-color: $checkbox-blue; + } + } + } + + &__label { + padding: 0; + } + + &__box { + display: block; + position: relative; + + height: 100%; + width: 100%; + border: 2px solid darken($gainsboro, 10%); + cursor: pointer; + } +} diff --git a/src/newsreader/scss/elements/checkbox/index.scss b/src/newsreader/scss/elements/checkbox/index.scss new file mode 100644 index 0000000..b022713 --- /dev/null +++ b/src/newsreader/scss/elements/checkbox/index.scss @@ -0,0 +1 @@ +@import './checkbox'; diff --git a/src/newsreader/scss/elements/h1/_h1.scss b/src/newsreader/scss/elements/h1/_h1.scss index d82b6eb..6ddee05 100644 --- a/src/newsreader/scss/elements/h1/_h1.scss +++ b/src/newsreader/scss/elements/h1/_h1.scss @@ -1,3 +1,8 @@ .h1 { - font-family: $header-font; + color: $header-color; + font-size: 20px; +} + +h1 { + @extend .h1; } diff --git a/src/newsreader/scss/elements/h1/index.scss b/src/newsreader/scss/elements/h1/index.scss index 0fe45be..a04aefc 100644 --- a/src/newsreader/scss/elements/h1/index.scss +++ b/src/newsreader/scss/elements/h1/index.scss @@ -1 +1 @@ -@import "h1"; +@import './h1'; diff --git a/src/newsreader/scss/elements/h2/_h2.scss b/src/newsreader/scss/elements/h2/_h2.scss index 18abeb4..9f77ad8 100644 --- a/src/newsreader/scss/elements/h2/_h2.scss +++ b/src/newsreader/scss/elements/h2/_h2.scss @@ -1,3 +1,7 @@ .h2 { - font-family: $header-font; + color: $header-color; +} + +h2 { + @extend .h2; } diff --git a/src/newsreader/scss/elements/h2/index.scss b/src/newsreader/scss/elements/h2/index.scss index 6ca9eab..9e9be3d 100644 --- a/src/newsreader/scss/elements/h2/index.scss +++ b/src/newsreader/scss/elements/h2/index.scss @@ -1 +1 @@ -@import "h2"; +@import './h2'; diff --git a/src/newsreader/scss/elements/h3/_h3.scss b/src/newsreader/scss/elements/h3/_h3.scss index 955967b..7394bd7 100644 --- a/src/newsreader/scss/elements/h3/_h3.scss +++ b/src/newsreader/scss/elements/h3/_h3.scss @@ -1,3 +1,7 @@ .h3 { - font-family: $header-font; + color: $header-color; +} + +h3 { + @extend .h3; } diff --git a/src/newsreader/scss/elements/h3/index.scss b/src/newsreader/scss/elements/h3/index.scss index feb5951..c1aeae5 100644 --- a/src/newsreader/scss/elements/h3/index.scss +++ b/src/newsreader/scss/elements/h3/index.scss @@ -1 +1 @@ -@import "h3"; +@import './h3'; diff --git a/src/newsreader/scss/elements/h4/_h4.scss b/src/newsreader/scss/elements/h4/_h4.scss new file mode 100644 index 0000000..ab24888 --- /dev/null +++ b/src/newsreader/scss/elements/h4/_h4.scss @@ -0,0 +1,7 @@ +.h4 { + color: $header-color; +} + +h4 { + @extend .h4; +} diff --git a/src/newsreader/scss/elements/h4/index.scss b/src/newsreader/scss/elements/h4/index.scss new file mode 100644 index 0000000..4c735ff --- /dev/null +++ b/src/newsreader/scss/elements/h4/index.scss @@ -0,0 +1 @@ +@import './h4'; diff --git a/src/newsreader/scss/elements/h5/_h5.scss b/src/newsreader/scss/elements/h5/_h5.scss new file mode 100644 index 0000000..5efa58e --- /dev/null +++ b/src/newsreader/scss/elements/h5/_h5.scss @@ -0,0 +1,7 @@ +.h5 { + color: $header-color; +} + +h5 { + @extend .h5; +} diff --git a/src/newsreader/scss/elements/h5/index.scss b/src/newsreader/scss/elements/h5/index.scss new file mode 100644 index 0000000..2781c4f --- /dev/null +++ b/src/newsreader/scss/elements/h5/index.scss @@ -0,0 +1 @@ +@import './h5'; diff --git a/src/newsreader/scss/elements/help-text/index.scss b/src/newsreader/scss/elements/help-text/index.scss index f5f595f..1f8cde9 100644 --- a/src/newsreader/scss/elements/help-text/index.scss +++ b/src/newsreader/scss/elements/help-text/index.scss @@ -1 +1 @@ -@import "help-text"; +@import './help-text'; diff --git a/src/newsreader/scss/elements/index.scss b/src/newsreader/scss/elements/index.scss index 3e2a01c..0c30aff 100644 --- a/src/newsreader/scss/elements/index.scss +++ b/src/newsreader/scss/elements/index.scss @@ -1,11 +1,14 @@ -@import "badge/index"; -@import "button/index"; -@import "help-text/index"; -@import "input/index"; -@import "label/index"; -@import "link/index"; -@import "h1/index"; -@import "h2/index"; -@import "h3/index"; -@import "small/index"; -@import "select/index"; +@import './badge/index'; +@import './button/index'; +@import './help-text/index'; +@import './input/index'; +@import './label/index'; +@import './link/index'; +@import './h1/index'; +@import './h2/index'; +@import './h3/index'; +@import './h4/index'; +@import './h5/index'; +@import './small/index'; +@import './select/index'; +@import './checkbox/index'; diff --git a/src/newsreader/scss/elements/input/_input.scss b/src/newsreader/scss/elements/input/_input.scss index 897fbf9..8258020 100644 --- a/src/newsreader/scss/elements/input/_input.scss +++ b/src/newsreader/scss/elements/input/_input.scss @@ -1,9 +1,7 @@ .input { padding: 10px; - background-color: lighten($gainsboro, +4%); border: 1px $border-gray solid; - border-radius: 2px; &:focus { border: 1px $focus-blue solid; diff --git a/src/newsreader/scss/elements/input/index.scss b/src/newsreader/scss/elements/input/index.scss index 84e5ed8..d602311 100644 --- a/src/newsreader/scss/elements/input/index.scss +++ b/src/newsreader/scss/elements/input/index.scss @@ -1 +1 @@ -@import "input"; +@import './input'; diff --git a/src/newsreader/scss/elements/label/index.scss b/src/newsreader/scss/elements/label/index.scss index 12e5523..d893279 100644 --- a/src/newsreader/scss/elements/label/index.scss +++ b/src/newsreader/scss/elements/label/index.scss @@ -1 +1 @@ -@import "label"; +@import './label'; diff --git a/src/newsreader/scss/elements/link/index.scss b/src/newsreader/scss/elements/link/index.scss index bab69d1..1e73038 100644 --- a/src/newsreader/scss/elements/link/index.scss +++ b/src/newsreader/scss/elements/link/index.scss @@ -1 +1 @@ -@import "link"; +@import './link'; diff --git a/src/newsreader/scss/elements/select/index.scss b/src/newsreader/scss/elements/select/index.scss index 8320088..192ab81 100644 --- a/src/newsreader/scss/elements/select/index.scss +++ b/src/newsreader/scss/elements/select/index.scss @@ -1 +1 @@ -@import "select"; +@import './select'; diff --git a/src/newsreader/scss/elements/small/index.scss b/src/newsreader/scss/elements/small/index.scss index ea3d25f..c168f07 100644 --- a/src/newsreader/scss/elements/small/index.scss +++ b/src/newsreader/scss/elements/small/index.scss @@ -1 +1 @@ -@import "small"; +@import './small'; diff --git a/src/newsreader/scss/index.scss b/src/newsreader/scss/index.scss index 2dbf46a..6bd9025 100644 --- a/src/newsreader/scss/index.scss +++ b/src/newsreader/scss/index.scss @@ -1,6 +1,6 @@ -@import "lib/index"; -@import "partials/index"; -@import "components/index"; -@import "elements/index"; +@import './lib/index'; +@import './partials/index'; +@import './components/index'; +@import './elements/index'; -@import "pages/index"; +@import './pages/index'; diff --git a/src/newsreader/scss/lib/_css.gg.scss b/src/newsreader/scss/lib/_css.gg.scss index e7096d5..a1b1338 100644 --- a/src/newsreader/scss/lib/_css.gg.scss +++ b/src/newsreader/scss/lib/_css.gg.scss @@ -1,9 +1,10 @@ -@import "~css.gg/icons-scss/icons"; +@import '~css.gg/icons-scss/icons'; .gg-link { color: initial; } .gg-pen { + transform: rotate(-45deg) scale(var(--ggs, 0.8)); color: initial; } diff --git a/src/newsreader/scss/lib/_mixins.scss b/src/newsreader/scss/lib/_mixins.scss index e2d28aa..8b13789 100644 --- a/src/newsreader/scss/lib/_mixins.scss +++ b/src/newsreader/scss/lib/_mixins.scss @@ -1,3 +1 @@ -@mixin rounded { - border-radius: 5px; -} + diff --git a/src/newsreader/scss/lib/index.scss b/src/newsreader/scss/lib/index.scss index 2aca0df..ec6885e 100644 --- a/src/newsreader/scss/lib/index.scss +++ b/src/newsreader/scss/lib/index.scss @@ -1 +1 @@ -@import "css.gg"; +@import 'css.gg'; diff --git a/src/newsreader/scss/pages/index.scss b/src/newsreader/scss/pages/index.scss index ddfaf85..44ca8a7 100644 --- a/src/newsreader/scss/pages/index.scss +++ b/src/newsreader/scss/pages/index.scss @@ -1,14 +1,14 @@ -@import "categories/index"; -@import "category/index"; +@import './categories/index'; +@import './category/index'; -@import "import/index"; -@import "homepage/index"; +@import './import/index'; +@import './homepage/index'; -@import "login/index"; -@import "password-reset/index"; -@import "register/index"; +@import './login/index'; +@import './password-reset/index'; +@import './register/index'; -@import "rule/index"; -@import "rules/index"; +@import './rule/index'; +@import './rules/index'; -@import "settings/index"; +@import './settings/index'; diff --git a/src/newsreader/scss/partials/_colors.scss b/src/newsreader/scss/partials/_colors.scss index 8e776a2..08c7169 100644 --- a/src/newsreader/scss/partials/_colors.scss +++ b/src/newsreader/scss/partials/_colors.scss @@ -19,10 +19,10 @@ $lavendal-pink: rgba(162, 155, 254, 1); $beige: rgba(245, 245, 220, 1); $light-green: rgba(230, 247, 185, 1); -$light-orange: rgba(237, 212, 178, 1); +$light-orange: rgba(255, 212, 153, 1); $light-red: rgba(255, 118, 117, 1); -$success-green: rgba(46,204,113, 1); +$success-green: rgba(89, 181, 128, 1); $error-red: lighten(rgba(231, 76, 60, 1), 10%); $confirm-green: $success-green; @@ -30,8 +30,11 @@ $cancel-red: $error-red; $border-gray: rgba(227, 227, 227, 1); +$button-blue: rgba(111, 164, 196, 1); $focus-blue: darken($azureish-white, +10%); -$default-font-color: rgba(48, 51, 53, 1); +$checkbox-blue: rgba(34, 170, 253, 1); +$font-color: rgba(48, 51, 53, 1); +$header-color: rgba(100, 101, 102, 1); $white: rgba(255, 255, 255, 1); $black: rgba(0, 0, 0, 1); diff --git a/src/newsreader/scss/partials/_fonts.scss b/src/newsreader/scss/partials/_fonts.scss index 31c5d56..bcceb13 100644 --- a/src/newsreader/scss/partials/_fonts.scss +++ b/src/newsreader/scss/partials/_fonts.scss @@ -1,17 +1,10 @@ -@import url("https://fonts.googleapis.com/css?family=Barlow&display=swap"); -@import url("https://fonts.googleapis.com/css?family=Oswald&display=swap"); -@import url("https://fonts.googleapis.com/css?family=Nunito&display=swap"); -@import url("https://fonts.googleapis.com/css?family=Noto+Sans&display=swap"); -@import url("https://fonts.googleapis.com/css?family=Noto+Serif&display=swap"); -@import url("https://fonts.googleapis.com/css?family=IBM+Plex+Sans:600&display=swap"); +@font-face { + font-family: Rubik; + src: url('../assets/fonts/Rubik-Regular.ttf'); +} -$default-font: "Noto Serif", serif; - -$button-font: "IBM Plex Sans", sans-serif; -$form-font: "Barlow", sans-serif; - -$article-font: "Noto Serif", serif; -$article-header-font: "Oswald", sans-serif; - -$header-font: "Noto Sans", sans-serif; -$sidebar-font: "Nunito", sans-serif; +@font-face { + font-family: Rubik; + src: url('../assets/fonts/Rubik-Bold.ttf'); + font-weight: bold; +} diff --git a/src/newsreader/scss/partials/index.scss b/src/newsreader/scss/partials/index.scss index 24bbbd0..ff28d1b 100644 --- a/src/newsreader/scss/partials/index.scss +++ b/src/newsreader/scss/partials/index.scss @@ -1,2 +1,2 @@ -@import "fonts"; -@import "colors"; +@import './colors'; +@import './fonts'; diff --git a/src/newsreader/templates/components/form/attrs.html b/src/newsreader/templates/components/form/attrs.html new file mode 100644 index 0000000..50aec9b --- /dev/null +++ b/src/newsreader/templates/components/form/attrs.html @@ -0,0 +1,18 @@ +{% spaceless %} + {% for name, value in widget.attrs.items %} + {% if value is not False %} + {{ name }} + + {% if value is not True %} + ="{{ value|stringformat:'s' }}" + {% endif %} + {% endif %} + {% endfor %} + + {% if id %} id="{{ id }}"{% endif %} + {% if type %} type="{{ type }}"{% endif %} + {% if value %} value="{{ value }}"{% endif %} + {% if name %} name="{{ name }}"{% endif %} + {% if data_input %} data-input="{{ data_input }}"{% endif %} + {% if checked %} checked {% endif %} +{% endspaceless %} diff --git a/src/newsreader/templates/components/form/checkbox.html b/src/newsreader/templates/components/form/checkbox.html new file mode 100644 index 0000000..42ac691 --- /dev/null +++ b/src/newsreader/templates/components/form/checkbox.html @@ -0,0 +1,10 @@ +
      + {% if widget %} + {% include "components/form/input.html" with widget=widget %} + {% else %} + {% include "components/form/input.html" with id=id name=name type="checkbox" value=value data_input=data_input checked=checked %} + {% endif %} + +
      diff --git a/src/newsreader/templates/components/form/input.html b/src/newsreader/templates/components/form/input.html new file mode 100644 index 0000000..08f32e1 --- /dev/null +++ b/src/newsreader/templates/components/form/input.html @@ -0,0 +1,7 @@ +{% spaceless %} + +{% endspaceless %} diff --git a/src/newsreader/templates/django/forms/widgets/attrs.html b/src/newsreader/templates/django/forms/widgets/attrs.html new file mode 120000 index 0000000..595204e --- /dev/null +++ b/src/newsreader/templates/django/forms/widgets/attrs.html @@ -0,0 +1 @@ +../../../components/form/attrs.html \ No newline at end of file diff --git a/src/newsreader/templates/django/forms/widgets/checkbox.html b/src/newsreader/templates/django/forms/widgets/checkbox.html new file mode 120000 index 0000000..f939869 --- /dev/null +++ b/src/newsreader/templates/django/forms/widgets/checkbox.html @@ -0,0 +1 @@ +../../../components/form/checkbox.html \ No newline at end of file diff --git a/src/newsreader/utils/__init__.py b/src/newsreader/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/utils/admin.py b/src/newsreader/utils/admin.py new file mode 100644 index 0000000..846f6b4 --- /dev/null +++ b/src/newsreader/utils/admin.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/src/newsreader/utils/apps.py b/src/newsreader/utils/apps.py new file mode 100644 index 0000000..3e82e49 --- /dev/null +++ b/src/newsreader/utils/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class UtilsConfig(AppConfig): + name = "utils" diff --git a/src/newsreader/utils/form.py b/src/newsreader/utils/form.py new file mode 100644 index 0000000..dc619bf --- /dev/null +++ b/src/newsreader/utils/form.py @@ -0,0 +1,16 @@ +from django.forms.renderers import DjangoTemplates +from django.template.exceptions import TemplateDoesNotExist +from django.template.loader import get_template + + +class FormRenderer(DjangoTemplates): + """ + Prioritizes templates from TEMPLATES setting and fall's back to django's + default FormRenderer behaviour + """ + + def get_template(self, template_name): + try: + return get_template(template_name) + except TemplateDoesNotExist: + return super().get_template(template_name) diff --git a/src/newsreader/utils/migrations/__init__.py b/src/newsreader/utils/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/utils/models.py b/src/newsreader/utils/models.py new file mode 100644 index 0000000..6b20219 --- /dev/null +++ b/src/newsreader/utils/models.py @@ -0,0 +1 @@ +# Create your models here. diff --git a/src/newsreader/utils/templatetags/filters.py b/src/newsreader/utils/templatetags/filters.py new file mode 100644 index 0000000..94fa541 --- /dev/null +++ b/src/newsreader/utils/templatetags/filters.py @@ -0,0 +1,9 @@ +from django import template + + +register = template.Library() + + +@register.filter +def id_for_label(instance, arg): + return f"{arg}_{instance.pk}" diff --git a/src/newsreader/utils/views.py b/src/newsreader/utils/views.py new file mode 100644 index 0000000..60f00ef --- /dev/null +++ b/src/newsreader/utils/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/webpack.common.babel.js b/webpack.common.babel.js index 2c8471c..4ad1700 100644 --- a/webpack.common.babel.js +++ b/webpack.common.babel.js @@ -21,6 +21,16 @@ export default { test: /\.(sass|scss)$/, use: [{ loader: MiniCssExtractPlugin.loader }, 'css-loader', 'sass-loader'], }, + { + test: /\.(ttf|woff|woff2)$/, + use: { + loader: 'file-loader', + options: { + name: 'fonts/[name].[ext]', + publicPath: '../', + }, + }, + }, ], }, plugins: [ From 2d3eae6e39dea2ba3bfbb2821a3061412417f82d Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 7 Jun 2020 10:29:51 +0200 Subject: [PATCH 096/422] Remove django entrypoint script --- docker-compose.yml | 2 +- src/entrypoint.sh | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100755 src/entrypoint.sh diff --git a/docker-compose.yml b/docker-compose.yml index e168162..a72bb82 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,7 +35,7 @@ services: build: context: . dockerfile: ./docker/django - command: src/entrypoint.sh + command: python /app/src/manage.py runserver 0.0.0.0:8000 environment: - DJANGO_SETTINGS_MODULE=newsreader.conf.docker ports: diff --git a/src/entrypoint.sh b/src/entrypoint.sh deleted file mode 100755 index 3fbf941..0000000 --- a/src/entrypoint.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash -# This file should only be used in conjuction with docker-compose - -python /app/src/manage.py migrate -python /app/src/manage.py runserver 0.0.0.0:8000 From 3b2384a26670946c0962b9534661cd73cf1542dc Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 7 Jun 2020 21:29:21 +0200 Subject: [PATCH 097/422] Update django version --- poetry.lock | 8 ++++---- pyproject.toml | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 0681268..8cad374 100644 --- a/poetry.lock +++ b/poetry.lock @@ -219,7 +219,7 @@ description = "A high-level Python Web framework that encourages rapid developme name = "django" optional = false python-versions = ">=3.6" -version = "3.0.5" +version = "3.0.7" [package.dependencies] asgiref = ">=3.2,<4.0" @@ -768,7 +768,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] [metadata] -content-hash = "38cc29547dab994d438a7a4082fca9f557acfff59626df37ec9ee9f15ff094a0" +content-hash = "479ed51fef7eb2990163f57ff4255887cd559deea9bb631fd8e3ca81e6939715" python-versions = "^3.7" [metadata.files] @@ -866,8 +866,8 @@ coverage = [ {file = "coverage-5.1.tar.gz", hash = "sha256:f90bfc4ad18450c80b024036eaf91e4a246ae287701aaa88eaebebf150868052"}, ] django = [ - {file = "Django-3.0.5-py3-none-any.whl", hash = "sha256:642d8eceab321ca743ae71e0f985ff8fdca59f07aab3a9fb362c617d23e33a76"}, - {file = "Django-3.0.5.tar.gz", hash = "sha256:d4666c2edefa38c5ede0ec1655424c56dc47ceb04b6d8d62a7eac09db89545c1"}, + {file = "Django-3.0.7-py3-none-any.whl", hash = "sha256:e1630333248c9b3d4e38f02093a26f1e07b271ca896d73097457996e0fae12e8"}, + {file = "Django-3.0.7.tar.gz", hash = "sha256:5052b34b34b3425233c682e0e11d658fd6efd587d11335a0203d827224ada8f2"}, ] django-appconf = [ {file = "django-appconf-1.0.4.tar.gz", hash = "sha256:be58deb54a43d77d2e1621fe59f787681376d3cd0b8bd8e4758ef6c3a6453380"}, diff --git a/pyproject.toml b/pyproject.toml index 047c544..71b5efc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ requests = "^2.23.0" psycopg2-binary = "^2.8.5" gunicorn = "^20.0.4" python-dotenv = "^0.12.0" +django = ">=3.0.7" [tool.poetry.dev-dependencies] factory-boy = "^2.12.0" From 6609b1306bd06ab991d0c4d4b8e2ab4dfbbd0020 Mon Sep 17 00:00:00 2001 From: sonny Date: Sun, 7 Jun 2020 21:32:49 +0200 Subject: [PATCH 098/422] Update django version --- docker-compose.yml | 2 +- poetry.lock | 8 ++++---- pyproject.toml | 1 + src/entrypoint.sh | 5 ----- 4 files changed, 6 insertions(+), 10 deletions(-) delete mode 100755 src/entrypoint.sh diff --git a/docker-compose.yml b/docker-compose.yml index e168162..a72bb82 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,7 +35,7 @@ services: build: context: . dockerfile: ./docker/django - command: src/entrypoint.sh + command: python /app/src/manage.py runserver 0.0.0.0:8000 environment: - DJANGO_SETTINGS_MODULE=newsreader.conf.docker ports: diff --git a/poetry.lock b/poetry.lock index 0681268..8cad374 100644 --- a/poetry.lock +++ b/poetry.lock @@ -219,7 +219,7 @@ description = "A high-level Python Web framework that encourages rapid developme name = "django" optional = false python-versions = ">=3.6" -version = "3.0.5" +version = "3.0.7" [package.dependencies] asgiref = ">=3.2,<4.0" @@ -768,7 +768,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] [metadata] -content-hash = "38cc29547dab994d438a7a4082fca9f557acfff59626df37ec9ee9f15ff094a0" +content-hash = "479ed51fef7eb2990163f57ff4255887cd559deea9bb631fd8e3ca81e6939715" python-versions = "^3.7" [metadata.files] @@ -866,8 +866,8 @@ coverage = [ {file = "coverage-5.1.tar.gz", hash = "sha256:f90bfc4ad18450c80b024036eaf91e4a246ae287701aaa88eaebebf150868052"}, ] django = [ - {file = "Django-3.0.5-py3-none-any.whl", hash = "sha256:642d8eceab321ca743ae71e0f985ff8fdca59f07aab3a9fb362c617d23e33a76"}, - {file = "Django-3.0.5.tar.gz", hash = "sha256:d4666c2edefa38c5ede0ec1655424c56dc47ceb04b6d8d62a7eac09db89545c1"}, + {file = "Django-3.0.7-py3-none-any.whl", hash = "sha256:e1630333248c9b3d4e38f02093a26f1e07b271ca896d73097457996e0fae12e8"}, + {file = "Django-3.0.7.tar.gz", hash = "sha256:5052b34b34b3425233c682e0e11d658fd6efd587d11335a0203d827224ada8f2"}, ] django-appconf = [ {file = "django-appconf-1.0.4.tar.gz", hash = "sha256:be58deb54a43d77d2e1621fe59f787681376d3cd0b8bd8e4758ef6c3a6453380"}, diff --git a/pyproject.toml b/pyproject.toml index 047c544..71b5efc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ requests = "^2.23.0" psycopg2-binary = "^2.8.5" gunicorn = "^20.0.4" python-dotenv = "^0.12.0" +django = ">=3.0.7" [tool.poetry.dev-dependencies] factory-boy = "^2.12.0" diff --git a/src/entrypoint.sh b/src/entrypoint.sh deleted file mode 100755 index 3fbf941..0000000 --- a/src/entrypoint.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash -# This file should only be used in conjuction with docker-compose - -python /app/src/manage.py migrate -python /app/src/manage.py runserver 0.0.0.0:8000 From 4dfa755881aec4647839635685ef469ece26f775 Mon Sep 17 00:00:00 2001 From: sonny Date: Mon, 15 Jun 2020 22:56:06 +0200 Subject: [PATCH 099/422] Use django.forms.renderers.TemplatesSetting As this doesn't ignore the DIRS setting in TEMPLATES --- src/newsreader/conf/base.py | 5 +++-- src/newsreader/conf/dev.py | 16 ---------------- src/newsreader/utils/form.py | 16 ---------------- 3 files changed, 3 insertions(+), 34 deletions(-) delete mode 100644 src/newsreader/utils/form.py diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 5d46230..c911462 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -22,6 +22,7 @@ INSTALLED_APPS = [ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "django.forms", # third party apps "rest_framework", "drf_yasg", @@ -55,6 +56,8 @@ MIDDLEWARE = [ ROOT_URLCONF = "newsreader.urls" +FORM_RENDERER = "django.forms.renderers.TemplatesSetting" + TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", @@ -71,8 +74,6 @@ TEMPLATES = [ } ] -FORM_RENDERER = "newsreader.utils.form.FormRenderer" - WSGI_APPLICATION = "newsreader.wsgi.application" # Database diff --git a/src/newsreader/conf/dev.py b/src/newsreader/conf/dev.py index b81a9fa..8186db7 100644 --- a/src/newsreader/conf/dev.py +++ b/src/newsreader/conf/dev.py @@ -9,22 +9,6 @@ EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" INSTALLED_APPS += ["debug_toolbar", "django_extensions"] -TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")], - "APP_DIRS": True, - "OPTIONS": { - "context_processors": [ - "django.template.context_processors.debug", - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", - ] - }, - } -] - # Third party settings AXES_FAILURE_LIMIT = 50 AXES_COOLOFF_TIME = None diff --git a/src/newsreader/utils/form.py b/src/newsreader/utils/form.py deleted file mode 100644 index dc619bf..0000000 --- a/src/newsreader/utils/form.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.forms.renderers import DjangoTemplates -from django.template.exceptions import TemplateDoesNotExist -from django.template.loader import get_template - - -class FormRenderer(DjangoTemplates): - """ - Prioritizes templates from TEMPLATES setting and fall's back to django's - default FormRenderer behaviour - """ - - def get_template(self, template_name): - try: - return get_template(template_name) - except TemplateDoesNotExist: - return super().get_template(template_name) From 061a2e852d8d6090eb108d1cba5da8ca269464db Mon Sep 17 00:00:00 2001 From: sonny Date: Tue, 16 Jun 2020 09:04:34 +0200 Subject: [PATCH 100/422] 0.2.3.3 - Fix admin --- src/newsreader/conf/base.py | 5 +++-- src/newsreader/conf/dev.py | 16 ---------------- src/newsreader/utils/form.py | 16 ---------------- 3 files changed, 3 insertions(+), 34 deletions(-) delete mode 100644 src/newsreader/utils/form.py diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 5d46230..c911462 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -22,6 +22,7 @@ INSTALLED_APPS = [ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "django.forms", # third party apps "rest_framework", "drf_yasg", @@ -55,6 +56,8 @@ MIDDLEWARE = [ ROOT_URLCONF = "newsreader.urls" +FORM_RENDERER = "django.forms.renderers.TemplatesSetting" + TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", @@ -71,8 +74,6 @@ TEMPLATES = [ } ] -FORM_RENDERER = "newsreader.utils.form.FormRenderer" - WSGI_APPLICATION = "newsreader.wsgi.application" # Database diff --git a/src/newsreader/conf/dev.py b/src/newsreader/conf/dev.py index b81a9fa..8186db7 100644 --- a/src/newsreader/conf/dev.py +++ b/src/newsreader/conf/dev.py @@ -9,22 +9,6 @@ EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" INSTALLED_APPS += ["debug_toolbar", "django_extensions"] -TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")], - "APP_DIRS": True, - "OPTIONS": { - "context_processors": [ - "django.template.context_processors.debug", - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", - ] - }, - } -] - # Third party settings AXES_FAILURE_LIMIT = 50 AXES_COOLOFF_TIME = None diff --git a/src/newsreader/utils/form.py b/src/newsreader/utils/form.py deleted file mode 100644 index dc619bf..0000000 --- a/src/newsreader/utils/form.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.forms.renderers import DjangoTemplates -from django.template.exceptions import TemplateDoesNotExist -from django.template.loader import get_template - - -class FormRenderer(DjangoTemplates): - """ - Prioritizes templates from TEMPLATES setting and fall's back to django's - default FormRenderer behaviour - """ - - def get_template(self, template_name): - try: - return get_template(template_name) - except TemplateDoesNotExist: - return super().get_template(template_name) From c8771cb2728c962fb6c335857448d0d94baff2d8 Mon Sep 17 00:00:00 2001 From: Sonny Date: Tue, 16 Jun 2020 09:24:52 +0200 Subject: [PATCH 101/422] Update post admin --- src/newsreader/news/core/admin.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/newsreader/news/core/admin.py b/src/newsreader/news/core/admin.py index 3bcfc19..e8c3c4b 100644 --- a/src/newsreader/news/core/admin.py +++ b/src/newsreader/news/core/admin.py @@ -1,21 +1,35 @@ from django.contrib import admin +from django.db import models +from django.forms import Textarea, TextInput, URLInput from newsreader.news.core.models import Category, Post class PostAdmin(admin.ModelAdmin): - list_display = ("publication_date", "author", "rule", "title") + list_display = ("publication_date", "rule", "title") list_display_links = ("title",) list_filter = ("rule",) ordering = ("-publication_date", "title") - fields = ("title", "body", "author", "publication_date", "url") + fields = ( + "remote_identifier", + "rule", + "url", + "title", + "body", + "publication_date", + "author", + ) - search_fields = ["title"] + readonly_fields = ("remote_identifier", "rule") + search_fields = ("title", "author", "rule__name") - def rule(self, obj): - return obj.rule + formfield_overrides = { + models.CharField: {"widget": TextInput(attrs={"size": "100"})}, + models.URLField: {"widget": URLInput(attrs={"size": "100"})}, + models.TextField: {"widget": Textarea(attrs={"rows": 10, "cols": 100})}, + } class CategoryAdmin(admin.ModelAdmin): From 083f72840405cda0cc48c8a998c22c1e1a918b3b Mon Sep 17 00:00:00 2001 From: sonny Date: Tue, 16 Jun 2020 20:48:21 +0200 Subject: [PATCH 102/422] Use celery configuration similar to production --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index a72bb82..27d2969 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,7 +26,7 @@ services: build: context: . dockerfile: ./docker/django - command: celery worker --app newsreader --loglevel INFO --beat --scheduler django --workdir /app/src/ + command: celery worker -n worker1@%h -n worker2@%h --app newsreader --loglevel INFO --concurrency 2 --workdir /app/src/ --beat --scheduler django environment: - DJANGO_SETTINGS_MODULE=newsreader.conf.docker depends_on: From 722afe8c1249e24366bef92bafab2bced95bf928 Mon Sep 17 00:00:00 2001 From: sonny Date: Tue, 16 Jun 2020 20:56:38 +0200 Subject: [PATCH 103/422] Fix sidebar category overflow --- .../pages/homepage/components/sidebar/CategoryItem.js | 4 ++-- src/newsreader/scss/components/category/_category.scss | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js b/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js index 7c0268c..563f8ad 100644 --- a/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js +++ b/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js @@ -40,8 +40,8 @@ class CategoryItem extends React.Component {
      this.handleSelect()}> - {this.props.category.name} - {this.props.category.unread} + {this.props.category.name} + {this.props.category.unread}
      diff --git a/src/newsreader/scss/components/category/_category.scss b/src/newsreader/scss/components/category/_category.scss index 683ed0b..e8e1ba9 100644 --- a/src/newsreader/scss/components/category/_category.scss +++ b/src/newsreader/scss/components/category/_category.scss @@ -14,16 +14,18 @@ overflow: hidden; white-space: nowrap; - & h4 { - overflow: hidden; - text-overflow: ellipsis; - } &:hover { cursor: pointer; } } + &__name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + &__menu { display: flex; align-items: center; From 00f6427c579ba847de441c1ad163c0d8d8499201 Mon Sep 17 00:00:00 2001 From: sonny Date: Tue, 16 Jun 2020 21:04:49 +0200 Subject: [PATCH 104/422] 0.2.3.5 - Fix sidebar category overflow --- docker-compose.yml | 2 +- .../components/sidebar/CategoryItem.js | 4 ++-- src/newsreader/news/core/admin.py | 24 +++++++++++++++---- .../scss/components/category/_category.scss | 10 ++++---- 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index a72bb82..27d2969 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,7 +26,7 @@ services: build: context: . dockerfile: ./docker/django - command: celery worker --app newsreader --loglevel INFO --beat --scheduler django --workdir /app/src/ + command: celery worker -n worker1@%h -n worker2@%h --app newsreader --loglevel INFO --concurrency 2 --workdir /app/src/ --beat --scheduler django environment: - DJANGO_SETTINGS_MODULE=newsreader.conf.docker depends_on: diff --git a/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js b/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js index 7c0268c..563f8ad 100644 --- a/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js +++ b/src/newsreader/js/pages/homepage/components/sidebar/CategoryItem.js @@ -40,8 +40,8 @@ class CategoryItem extends React.Component {

      KYTC-A1C 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

      this.handleSelect()}> - {this.props.category.name} - {this.props.category.unread} + {this.props.category.name} + {this.props.category.unread}
      diff --git a/src/newsreader/news/core/admin.py b/src/newsreader/news/core/admin.py index 3bcfc19..e8c3c4b 100644 --- a/src/newsreader/news/core/admin.py +++ b/src/newsreader/news/core/admin.py @@ -1,21 +1,35 @@ from django.contrib import admin +from django.db import models +from django.forms import Textarea, TextInput, URLInput from newsreader.news.core.models import Category, Post class PostAdmin(admin.ModelAdmin): - list_display = ("publication_date", "author", "rule", "title") + list_display = ("publication_date", "rule", "title") list_display_links = ("title",) list_filter = ("rule",) ordering = ("-publication_date", "title") - fields = ("title", "body", "author", "publication_date", "url") + fields = ( + "remote_identifier", + "rule", + "url", + "title", + "body", + "publication_date", + "author", + ) - search_fields = ["title"] + readonly_fields = ("remote_identifier", "rule") + search_fields = ("title", "author", "rule__name") - def rule(self, obj): - return obj.rule + formfield_overrides = { + models.CharField: {"widget": TextInput(attrs={"size": "100"})}, + models.URLField: {"widget": URLInput(attrs={"size": "100"})}, + models.TextField: {"widget": Textarea(attrs={"rows": 10, "cols": 100})}, + } class CategoryAdmin(admin.ModelAdmin): diff --git a/src/newsreader/scss/components/category/_category.scss b/src/newsreader/scss/components/category/_category.scss index 683ed0b..e8e1ba9 100644 --- a/src/newsreader/scss/components/category/_category.scss +++ b/src/newsreader/scss/components/category/_category.scss @@ -14,16 +14,18 @@ overflow: hidden; white-space: nowrap; - & h4 { - overflow: hidden; - text-overflow: ellipsis; - } &:hover { cursor: pointer; } } + &__name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + &__menu { display: flex; align-items: center; From 0bd47f1bb0e649e9e23ff89374ed5f585c0fb4b3 Mon Sep 17 00:00:00 2001 From: sonny Date: Thu, 18 Jun 2020 20:07:37 +0200 Subject: [PATCH 105/422] Update duplicate handler --- src/newsreader/news/collection/feed.py | 35 +-- .../collection/tests/feed/builder/tests.py | 197 ++++++++++------- .../tests/feed/duplicate_handler/tests.py | 199 ++++++++++++------ src/newsreader/news/core/models.py | 2 +- 4 files changed, 269 insertions(+), 164 deletions(-) diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index 07090ce..35b0b1e 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -52,9 +52,7 @@ class FeedBuilder(Builder): entries = data.get("entries", []) instances = self.build(entries, stream.rule) - posts = duplicate_handler.check(instances) - - self.instances = [post for post in posts] + self.instances = duplicate_handler.check(instances) def build(self, entries, rule): field_mapping = { @@ -196,22 +194,27 @@ class FeedDuplicateHandler: def check(self, instances): deduplicated_instances = self.deduplicate_instances(instances) + checked_instances = [] for instance in deduplicated_instances: if instance.remote_identifier in self.existing_identifiers: existing_post = self.handle_duplicate_identifier(instance) - yield existing_post + checked_instances.append(existing_post) continue elif self.in_database(instance): existing_post = self.get_duplicate_in_database(instance) if self.in_time_slot(instance, existing_post): - yield self.update_existing_post(instance, existing_post) + checked_instances.append( + self.update_existing_post(instance, existing_post) + ) continue - yield instance + checked_instances.append(instance) + + return checked_instances def in_database(self, post): values = {field: getattr(post, field, None) for field in self.duplicate_fields} @@ -229,23 +232,29 @@ class FeedDuplicateHandler: return True def deduplicate_instances(self, instances): + sorted_instances = sorted( + instances, key=lambda instance: instance.publication_date, reverse=True + ) deduplicated_instances = [] - for instance in instances: + for instance in sorted_instances: + instance_identifier = instance.remote_identifier + duplicate = False + values = { field: getattr(instance, field, None) for field in self.duplicate_fields } - duplicate = False for deduplicated_instance in deduplicated_instances: deduplicated_identifier = deduplicated_instance.remote_identifier - instance_identifier = instance.remote_identifier has_identifiers = deduplicated_identifier and instance_identifier - if self.is_duplicate(deduplicated_instance, values): - duplicate = True - break - elif has_identifiers and deduplicated_identifier == instance_identifier: + is_same_identifier = ( + has_identifiers and deduplicated_identifier == instance_identifier + ) + is_duplicate = self.is_duplicate(deduplicated_instance, values) + + if is_duplicate or is_same_identifier: duplicate = True break diff --git a/src/newsreader/news/collection/tests/feed/builder/tests.py b/src/newsreader/news/collection/tests/feed/builder/tests.py index be13908..cfafa4f 100644 --- a/src/newsreader/news/collection/tests/feed/builder/tests.py +++ b/src/newsreader/news/collection/tests/feed/builder/tests.py @@ -16,6 +16,7 @@ from newsreader.news.core.tests.factories import PostFactory from .mocks import * +@freeze_time("2019-10-30 12:30:00") class FeedBuilderTestCase(TestCase): def setUp(self): self.maxDiff = None @@ -30,8 +31,10 @@ class FeedBuilderTestCase(TestCase): post = Post.objects.get() - d = datetime.combine(date(2019, 5, 20), time(hour=16, minute=7, second=37)) - aware_date = pytz.utc.localize(d) + publication_date = datetime.combine( + date(2019, 5, 20), time(hour=16, minute=7, second=37) + ) + aware_date = pytz.utc.localize(publication_date) self.assertEquals(post.publication_date, aware_date) self.assertEquals(Post.objects.count(), 1) @@ -57,49 +60,60 @@ class FeedBuilderTestCase(TestCase): with builder((multiple_mock, mock_stream)) as builder: builder.save() - posts = Post.objects.order_by("id") + posts = Post.objects.order_by("-publication_date") self.assertEquals(Post.objects.count(), 3) - first_post = posts[0] - second_post = posts[1] + post = posts[0] - d = datetime.combine(date(2019, 5, 20), time(hour=16, minute=7, second=37)) - aware_date = pytz.utc.localize(d) - - self.assertEquals(first_post.publication_date, aware_date) + publication_date = datetime.combine( + date(2019, 5, 20), time(hour=16, minute=32, second=38) + ) + aware_date = pytz.utc.localize(publication_date) self.assertEquals( - first_post.remote_identifier, + post.publication_date.strftime("%Y-%m-%d %H:%M:%S"), + aware_date.strftime("%Y-%m-%d %H:%M:%S"), + ) + + self.assertEquals( + post.remote_identifier, + "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + ) + + self.assertEquals( + post.url, "https://www.bbc.co.uk/news/uk-england-birmingham-48339080" + ) + + self.assertEquals( + post.title, "Birmingham head teacher threatened over LGBT lessons" + ) + + post = posts[1] + + publication_date = datetime.combine( + date(2019, 5, 20), time(hour=16, minute=7, second=37) + ) + aware_date = pytz.utc.localize(publication_date) + + self.assertEquals( + post.publication_date.strftime("%Y-%m-%d %H:%M:%S"), + aware_date.strftime("%Y-%m-%d %H:%M:%S"), + ) + + self.assertEquals( + post.remote_identifier, "https://www.bbc.co.uk/news/world-us-canada-48338168", ) self.assertEquals( - first_post.url, "https://www.bbc.co.uk/news/world-us-canada-48338168" + post.url, "https://www.bbc.co.uk/news/world-us-canada-48338168" ) self.assertEquals( - first_post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif" + post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif" ) - d = datetime.combine(date(2019, 5, 20), time(hour=12, minute=19, second=19)) - aware_date = pytz.utc.localize(d) - - self.assertEquals(second_post.publication_date, aware_date) - - self.assertEquals( - second_post.remote_identifier, - "https://www.bbc.co.uk/news/technology-48334739", - ) - - self.assertEquals( - second_post.url, "https://www.bbc.co.uk/news/technology-48334739" - ) - - self.assertEquals( - second_post.title, "Huawei's Android loss: How it affects you" - ) - - def test_entry_without_remote_identifier(self): + def test_entries_without_remote_identifier(self): builder = FeedBuilder rule = CollectionRuleFactory() mock_stream = MagicMock(rule=rule) @@ -107,27 +121,37 @@ class FeedBuilderTestCase(TestCase): with builder((mock_without_identifier, mock_stream)) as builder: builder.save() - posts = Post.objects.order_by("id") + posts = Post.objects.order_by("-publication_date") self.assertEquals(Post.objects.count(), 2) - first_post = posts[0] + post = posts[0] - d = datetime.combine(date(2019, 5, 20), time(hour=16, minute=7, second=37)) - aware_date = pytz.utc.localize(d) - - self.assertEquals(first_post.publication_date, aware_date) - - self.assertEquals(first_post.remote_identifier, None) + publication_date = datetime.combine( + date(2019, 5, 20), time(hour=16, minute=7, second=37) + ) + aware_date = pytz.utc.localize(publication_date) + self.assertEquals(post.publication_date, aware_date) + self.assertEquals(post.remote_identifier, None) self.assertEquals( - first_post.url, "https://www.bbc.co.uk/news/world-us-canada-48338168" + post.url, "https://www.bbc.co.uk/news/world-us-canada-48338168" + ) + self.assertEquals( + post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif" ) - self.assertEquals( - first_post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif" - ) + post = posts[1] + + publication_date = datetime.combine( + date(2019, 5, 20), time(hour=12, minute=19, second=19) + ) + aware_date = pytz.utc.localize(publication_date) + + self.assertEquals(post.publication_date, aware_date) + self.assertEquals(post.remote_identifier, None) + self.assertEquals(post.url, "https://www.bbc.co.uk/news/technology-48334739") + self.assertEquals(post.title, "Huawei's Android loss: How it affects you") - @freeze_time("2019-10-30 12:30:00") def test_entry_without_publication_date(self): builder = FeedBuilder rule = CollectionRuleFactory() @@ -136,25 +160,30 @@ class FeedBuilderTestCase(TestCase): with builder((mock_without_publish_date, mock_stream)) as builder: builder.save() - posts = Post.objects.order_by("id") + posts = Post.objects.order_by("-publication_date") self.assertEquals(Post.objects.count(), 2) - first_post = posts[0] - second_post = posts[1] + post = posts[0] - self.assertEquals(first_post.created, timezone.now()) self.assertEquals( - first_post.remote_identifier, + post.publication_date.strftime("%Y-%m-%d %H:%M"), "2019-10-30 12:30" + ) + self.assertEquals(post.created, timezone.now()) + self.assertEquals( + post.remote_identifier, "https://www.bbc.co.uk/news/world-us-canada-48338168", ) - self.assertEquals(second_post.created, timezone.now()) + post = posts[1] + self.assertEquals( - second_post.remote_identifier, - "https://www.bbc.co.uk/news/technology-48334739", + post.publication_date.strftime("%Y-%m-%d %H:%M"), "2019-10-30 12:30" + ) + self.assertEquals(post.created, timezone.now()) + self.assertEquals( + post.remote_identifier, "https://www.bbc.co.uk/news/technology-48334739" ) - @freeze_time("2019-10-30 12:30:00") def test_entry_without_url(self): builder = FeedBuilder rule = CollectionRuleFactory() @@ -163,25 +192,24 @@ class FeedBuilderTestCase(TestCase): with builder((mock_without_url, mock_stream)) as builder: builder.save() - posts = Post.objects.order_by("id") + posts = Post.objects.order_by("-publication_date") self.assertEquals(Post.objects.count(), 2) - first_post = posts[0] - second_post = posts[1] + post = posts[0] - self.assertEquals(first_post.created, timezone.now()) + self.assertEquals(post.created, timezone.now()) self.assertEquals( - first_post.remote_identifier, + post.remote_identifier, "https://www.bbc.co.uk/news/world-us-canada-48338168", ) - self.assertEquals(second_post.created, timezone.now()) + post = posts[1] + + self.assertEquals(post.created, timezone.now()) self.assertEquals( - second_post.remote_identifier, - "https://www.bbc.co.uk/news/technology-48334739", + post.remote_identifier, "https://www.bbc.co.uk/news/technology-48334739" ) - @freeze_time("2019-10-30 12:30:00") def test_entry_without_body(self): builder = FeedBuilder rule = CollectionRuleFactory() @@ -190,25 +218,32 @@ class FeedBuilderTestCase(TestCase): with builder((mock_without_body, mock_stream)) as builder: builder.save() - posts = Post.objects.order_by("id") + posts = Post.objects.order_by("-publication_date") + self.assertEquals(Post.objects.count(), 2) - first_post = posts[0] - second_post = posts[1] + post = posts[0] - self.assertEquals(first_post.created, timezone.now()) self.assertEquals( - first_post.remote_identifier, - "https://www.bbc.co.uk/news/world-us-canada-48338168", + post.created.strftime("%Y-%m-%d %H:%M:%S"), "2019-10-30 12:30:00" ) - - self.assertEquals(second_post.created, timezone.now()) self.assertEquals( - second_post.remote_identifier, + post.remote_identifier, "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", ) + self.assertEquals(post.body, "") + + post = posts[1] + + self.assertEquals( + post.created.strftime("%Y-%m-%d %H:%M:%S"), "2019-10-30 12:30:00" + ) + self.assertEquals( + post.remote_identifier, + "https://www.bbc.co.uk/news/world-us-canada-48338168", + ) + self.assertEquals(post.body, "") - @freeze_time("2019-10-30 12:30:00") def test_entry_without_author(self): builder = FeedBuilder rule = CollectionRuleFactory() @@ -217,23 +252,25 @@ class FeedBuilderTestCase(TestCase): with builder((mock_without_author, mock_stream)) as builder: builder.save() - posts = Post.objects.order_by("id") + posts = Post.objects.order_by("-publication_date") self.assertEquals(Post.objects.count(), 2) - first_post = posts[0] - second_post = posts[1] + post = posts[0] - self.assertEquals(first_post.created, timezone.now()) + self.assertEquals(post.created, timezone.now()) self.assertEquals( - first_post.remote_identifier, + post.remote_identifier, "https://www.bbc.co.uk/news/world-us-canada-48338168", ) + self.assertEquals(post.author, None) - self.assertEquals(second_post.created, timezone.now()) + post = posts[1] + + self.assertEquals(post.created, timezone.now()) self.assertEquals( - second_post.remote_identifier, - "https://www.bbc.co.uk/news/technology-48334739", + post.remote_identifier, "https://www.bbc.co.uk/news/technology-48334739" ) + self.assertEquals(post.author, None) def test_empty_entries(self): builder = FeedBuilder diff --git a/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py index 6ed8a59..109491b 100644 --- a/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py +++ b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py @@ -11,110 +11,137 @@ from newsreader.news.core.models import Post from newsreader.news.core.tests.factories import PostFactory +@freeze_time("2019-10-30 12:30:00") class FeedDuplicateHandlerTestCase(TestCase): def setUp(self): self.maxDiff = None def test_duplicate_entries_with_remote_identifiers(self): rule = CollectionRuleFactory() + existing_post = PostFactory.create( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", rule=rule ) - new_post = PostFactory.build( + + new_posts = PostFactory.build_batch( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", - title="title got updated", + publication_date=timezone.now() - timedelta(days=7), + rule=rule, + size=5, + ) + last_post = PostFactory.build( + remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", + publication_date=timezone.now(), rule=rule, ) with FeedDuplicateHandler(rule) as duplicate_handler: - posts_gen = duplicate_handler.check([new_post]) - posts = list(posts_gen) + posts = duplicate_handler.check((*new_posts, last_post)) self.assertEquals(len(posts), 1) post = posts[0] - existing_post.refresh_from_db() - self.assertEquals(existing_post.pk, post.pk) - self.assertEquals(post.publication_date, new_post.publication_date) - self.assertEquals(post.title, new_post.title) - self.assertEquals(post.body, new_post.body) - self.assertEquals(post.rule, new_post.rule) + self.assertEquals( + post.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + last_post.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + ) + self.assertEquals(post.title, last_post.title) + self.assertEquals(post.body, last_post.body) + self.assertEquals(post.rule, last_post.rule) self.assertEquals(post.read, False) - @freeze_time("2019-10-30 12:30:00") def test_duplicate_entries_with_different_remote_identifiers(self): rule = CollectionRuleFactory() - publication_date = timezone.now() - existing_post = PostFactory.create( + existing_post = PostFactory( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", url="https://bbc.com", title="New post", body="Body", - publication_date=publication_date, + publication_date=timezone.now() - timedelta(minutes=10), rule=rule, ) - new_post = PostFactory.build( + + new_posts = PostFactory.build_batch( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7Q", url="https://bbc.com", title="New post", body="Body", - publication_date=publication_date, + publication_date=timezone.now() - timedelta(minutes=5), + rule=rule, + size=5, + ) + last_post = PostFactory.build( + remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7Q", + url="https://bbc.com", + title="New post", + body="Body", + publication_date=timezone.now(), rule=rule, ) with FeedDuplicateHandler(rule) as duplicate_handler: - posts_gen = duplicate_handler.check([new_post]) - posts = list(posts_gen) + posts = duplicate_handler.check((*new_posts, last_post)) self.assertEquals(len(posts), 1) - existing_post.refresh_from_db() post = posts[0] - self.assertEquals(existing_post.pk, post.pk) - self.assertEquals(post.title, new_post.title) - self.assertEquals(post.body, new_post.body) - self.assertEquals(post.rule, new_post.rule) - self.assertEquals(post.publication_date, new_post.publication_date) + self.assertEquals( + post.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + last_post.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + ) + self.assertEquals(post.title, last_post.title) + self.assertEquals(post.body, last_post.body) + self.assertEquals(post.rule, last_post.rule) self.assertEquals(post.read, False) def test_duplicate_entries_in_recent_database(self): - publication_date = timezone.now() - rule = CollectionRuleFactory() - existing_post = PostFactory.create( + + existing_post = PostFactory( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", title="Birmingham head teacher threatened over LGBT lessons", body="Google's move to end business ties with Huawei will affect current devices", - publication_date=publication_date, - remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", + publication_date=timezone.now() - timedelta(minutes=10), + remote_identifier=None, rule=rule, ) - new_post = PostFactory.build( + + new_posts = PostFactory.build_batch( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", title="Birmingham head teacher threatened over LGBT lessons", body="Google's move to end business ties with Huawei will affect current devices", - publication_date=publication_date, + publication_date=timezone.now() - timedelta(minutes=5), + remote_identifier=None, + rule=rule, + size=5, + ) + + last_post = PostFactory.build( + url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + title="Birmingham head teacher threatened over LGBT lessons", + body="Google's move to end business ties with Huawei will affect current devices", + publication_date=timezone.now(), remote_identifier=None, rule=rule, ) with FeedDuplicateHandler(rule) as duplicate_handler: - posts_gen = duplicate_handler.check([new_post]) - posts = list(posts_gen) + posts = duplicate_handler.check((*new_posts, last_post)) self.assertEquals(len(posts), 1) - existing_post.refresh_from_db() post = posts[0] - self.assertEquals(existing_post.pk, post.pk) - self.assertEquals(post.title, new_post.title) - self.assertEquals(post.body, new_post.body) - self.assertEquals(post.rule, new_post.rule) - self.assertEquals(post.publication_date, new_post.publication_date) + self.assertEquals( + post.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + last_post.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + ) + self.assertEquals(post.title, last_post.title) + self.assertEquals(post.body, last_post.body) + self.assertEquals(post.rule, last_post.rule) self.assertEquals(post.read, False) def test_multiple_existing_entries_with_identifier(self): @@ -124,15 +151,20 @@ class FeedDuplicateHandlerTestCase(TestCase): remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", rule=rule, size=5 ) - new_post = PostFactory.build( + new_posts = PostFactory.build_batch( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", - title="This is a new one", + publication_date=timezone.now() - timedelta(hours=5), + rule=rule, + size=5, + ) + last_post = PostFactory.build( + remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", + publication_date=timezone.now() - timedelta(minutes=5), rule=rule, ) with FeedDuplicateHandler(rule) as duplicate_handler: - posts_gen = duplicate_handler.check([new_post]) - posts = list(posts_gen) + posts = duplicate_handler.check((*new_posts, last_post)) self.assertEquals(len(posts), 1) @@ -145,77 +177,101 @@ class FeedDuplicateHandlerTestCase(TestCase): post = posts[0] - self.assertEquals(post.title, new_post.title) - self.assertEquals(post.body, new_post.body) - self.assertEquals(post.publication_date, new_post.publication_date) - self.assertEquals(post.rule, new_post.rule) + self.assertEquals( + post.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + last_post.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + ) + self.assertEquals(post.title, last_post.title) + self.assertEquals(post.body, last_post.body) + self.assertEquals(post.rule, last_post.rule) self.assertEquals(post.read, False) - @freeze_time("2019-10-30 12:30:00") def test_duplicate_entries_outside_time_slot(self): - publication_date = timezone.now() - rule = CollectionRuleFactory() - existing_post = PostFactory.create( + + existing_post = PostFactory( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", title="Birmingham head teacher threatened over LGBT lessons", body="Google's move to end business ties with Huawei will affect current devices", - publication_date=publication_date, - remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", + publication_date=timezone.now(), + remote_identifier=None, rule=rule, ) - new_post = PostFactory.build( + + new_posts = PostFactory.build_batch( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", title="Birmingham head teacher threatened over LGBT lessons", body="Google's move to end business ties with Huawei will affect current devices", - publication_date=publication_date + timedelta(minutes=12), + publication_date=timezone.now() + timedelta(minutes=12), + remote_identifier=None, + rule=rule, + size=5, + ) + last_post = PostFactory.build( + url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + title="Birmingham head teacher threatened over LGBT lessons", + body="Google's move to end business ties with Huawei will affect current devices", + publication_date=timezone.now() + timedelta(minutes=13), remote_identifier=None, rule=rule, ) with FeedDuplicateHandler(rule) as duplicate_handler: - posts_gen = duplicate_handler.check([new_post]) - posts = list(posts_gen) + posts = duplicate_handler.check((*new_posts, last_post)) self.assertEquals(len(posts), 1) - existing_post.refresh_from_db() post = posts[0] self.assertEquals(post.pk, None) - self.assertEquals(post.title, new_post.title) - self.assertEquals(post.body, new_post.body) - self.assertEquals(post.rule, new_post.rule) - self.assertEquals(post.publication_date, new_post.publication_date) + self.assertEquals( + post.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + last_post.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + ) + self.assertEquals(post.title, last_post.title) + self.assertEquals(post.body, last_post.body) + self.assertEquals(post.rule, last_post.rule) self.assertEquals(post.read, False) def test_duplicate_entries_in_collected_entries(self): rule = CollectionRuleFactory() post_1 = PostFactory.build( - title="title got updated", body="body", url="https://bbc.com", rule=rule + title="title got updated", + body="body", + url="https://bbc.com", + publication_date=timezone.now(), + rule=rule, ) duplicate_post_1 = PostFactory.build( - title="title got updated", body="body", url="https://bbc.com", rule=rule + title="title got updated", + body="body", + url="https://bbc.com", + publication_date=timezone.now() - timedelta(minutes=5), + rule=rule, ) post_2 = PostFactory.build( - remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7" + remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", + publication_date=timezone.now(), ) duplicate_post_2 = PostFactory.build( - remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7" + remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", + publication_date=timezone.now() - timedelta(minutes=5), ) collected_posts = (post_1, post_2, duplicate_post_1, duplicate_post_2) with FeedDuplicateHandler(rule) as duplicate_handler: - posts_gen = duplicate_handler.check(collected_posts) - posts = list(posts_gen) + posts = duplicate_handler.check(collected_posts) self.assertEquals(len(posts), 2) post = posts[0] - self.assertEquals(post_1.publication_date, post.publication_date) + self.assertEquals( + post_1.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + post.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + ) self.assertEquals(post_1.title, post.title) self.assertEquals(post_1.body, post.body) self.assertEquals(post_1.rule, post.rule) @@ -223,7 +279,10 @@ class FeedDuplicateHandlerTestCase(TestCase): post = posts[1] - self.assertEquals(post_2.publication_date, post.publication_date) + self.assertEquals( + post_2.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + post.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + ) self.assertEquals(post_2.title, post.title) self.assertEquals(post_2.body, post.body) self.assertEquals(post_2.rule, post.rule) diff --git a/src/newsreader/news/core/models.py b/src/newsreader/news/core/models.py index 28bf3fd..ff44c81 100644 --- a/src/newsreader/news/core/models.py +++ b/src/newsreader/news/core/models.py @@ -8,7 +8,7 @@ from newsreader.news.collection.models import CollectionRule class Post(TimeStampedModel): title = models.CharField(max_length=200, blank=True, null=True) - body = models.TextField(blank=True, null=True) + body = models.TextField(blank=True) author = models.CharField(max_length=40, blank=True, null=True) publication_date = models.DateTimeField(default=timezone.now) url = models.URLField(max_length=1024, blank=True, null=True) From bcd051e99266d87d07a7dfaca9df81f0077bc151 Mon Sep 17 00:00:00 2001 From: sonny Date: Thu, 18 Jun 2020 20:18:05 +0200 Subject: [PATCH 106/422] Update logging --- src/newsreader/conf/base.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index c911462..85a62ba 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -103,7 +103,7 @@ CACHES = { # https://docs.djangoproject.com/en/2.2/topics/logging/#configuring-logging LOGGING = { "version": 1, - "disable_existing_loggers": False, + "disable_existing_loggers": True, "filters": { "require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}, "require_debug_true": {"()": "django.utils.log.RequireDebugTrue"}, @@ -114,7 +114,11 @@ LOGGING = { "format": "[{server_time}] {message}", "style": "{", }, - "syslog": {"class": "logging.Formatter", "format": "{message}", "style": "{"}, + "syslog": { + "class": "logging.Formatter", + "format": "[newsreader] {message}", + "style": "{", + }, }, "handlers": { "console": { @@ -124,6 +128,7 @@ LOGGING = { }, "django.server": { "level": "INFO", + "filters": ["require_debug_true"], "class": "logging.StreamHandler", "formatter": "django.server", }, @@ -157,7 +162,6 @@ LOGGING = { "level": "INFO", "propagate": False, }, - "celery": {"handlers": ["syslog", "console"], "level": "INFO"}, "celery.task": {"handlers": ["syslog", "console"], "level": "INFO"}, }, } From 017dd9a582a0484f3c1ced98b464703142d52e43 Mon Sep 17 00:00:00 2001 From: sonny Date: Thu, 18 Jun 2020 20:23:47 +0200 Subject: [PATCH 107/422] Make poetry/pip less verbose --- gitlab-ci/lint.yml | 4 ++-- gitlab-ci/test.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/gitlab-ci/lint.yml b/gitlab-ci/lint.yml index 3f1e259..134716f 100644 --- a/gitlab-ci/lint.yml +++ b/gitlab-ci/lint.yml @@ -3,10 +3,10 @@ python-linting: allow_failure: true image: python:3.7.4-slim-stretch before_script: - - pip install poetry + - pip install poetry --quiet - poetry config cache-dir ~/.cache/poetry - poetry config virtualenvs.in-project true - - poetry install --no-interaction + - poetry install --no-interaction --quiet script: - poetry run isort src/ --check-only --recursive - poetry run black src/ --line-length 88 --check diff --git a/gitlab-ci/test.yml b/gitlab-ci/test.yml index 3e8eccb..723a0e8 100644 --- a/gitlab-ci/test.yml +++ b/gitlab-ci/test.yml @@ -6,10 +6,10 @@ python-tests: - memcached:1.5.22 image: python:3.7.4-slim-stretch before_script: - - pip install poetry + - pip install poetry --quiet - poetry config cache-dir .cache/poetry - poetry config virtualenvs.in-project true - - poetry install --no-interaction + - poetry install --no-interaction --quiet script: - poetry run coverage run src/manage.py test newsreader - poetry run coverage report From 2be35bce53219fb58177ce11b18149b86db525bb Mon Sep 17 00:00:00 2001 From: sonny Date: Thu, 18 Jun 2020 20:29:48 +0200 Subject: [PATCH 108/422] 0.2.3.6 - Update logging - Update FeedDuplicateHandler --- gitlab-ci/lint.yml | 4 +- gitlab-ci/test.yml | 4 +- src/newsreader/conf/base.py | 10 +- src/newsreader/news/collection/feed.py | 35 +-- .../collection/tests/feed/builder/tests.py | 197 ++++++++++------- .../tests/feed/duplicate_handler/tests.py | 199 ++++++++++++------ src/newsreader/news/core/models.py | 2 +- 7 files changed, 280 insertions(+), 171 deletions(-) diff --git a/gitlab-ci/lint.yml b/gitlab-ci/lint.yml index 3f1e259..134716f 100644 --- a/gitlab-ci/lint.yml +++ b/gitlab-ci/lint.yml @@ -3,10 +3,10 @@ python-linting: allow_failure: true image: python:3.7.4-slim-stretch before_script: - - pip install poetry + - pip install poetry --quiet - poetry config cache-dir ~/.cache/poetry - poetry config virtualenvs.in-project true - - poetry install --no-interaction + - poetry install --no-interaction --quiet script: - poetry run isort src/ --check-only --recursive - poetry run black src/ --line-length 88 --check diff --git a/gitlab-ci/test.yml b/gitlab-ci/test.yml index 3e8eccb..723a0e8 100644 --- a/gitlab-ci/test.yml +++ b/gitlab-ci/test.yml @@ -6,10 +6,10 @@ python-tests: - memcached:1.5.22 image: python:3.7.4-slim-stretch before_script: - - pip install poetry + - pip install poetry --quiet - poetry config cache-dir .cache/poetry - poetry config virtualenvs.in-project true - - poetry install --no-interaction + - poetry install --no-interaction --quiet script: - poetry run coverage run src/manage.py test newsreader - poetry run coverage report diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index c911462..85a62ba 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -103,7 +103,7 @@ CACHES = { # https://docs.djangoproject.com/en/2.2/topics/logging/#configuring-logging LOGGING = { "version": 1, - "disable_existing_loggers": False, + "disable_existing_loggers": True, "filters": { "require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}, "require_debug_true": {"()": "django.utils.log.RequireDebugTrue"}, @@ -114,7 +114,11 @@ LOGGING = { "format": "[{server_time}] {message}", "style": "{", }, - "syslog": {"class": "logging.Formatter", "format": "{message}", "style": "{"}, + "syslog": { + "class": "logging.Formatter", + "format": "[newsreader] {message}", + "style": "{", + }, }, "handlers": { "console": { @@ -124,6 +128,7 @@ LOGGING = { }, "django.server": { "level": "INFO", + "filters": ["require_debug_true"], "class": "logging.StreamHandler", "formatter": "django.server", }, @@ -157,7 +162,6 @@ LOGGING = { "level": "INFO", "propagate": False, }, - "celery": {"handlers": ["syslog", "console"], "level": "INFO"}, "celery.task": {"handlers": ["syslog", "console"], "level": "INFO"}, }, } diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index 07090ce..35b0b1e 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -52,9 +52,7 @@ class FeedBuilder(Builder): entries = data.get("entries", []) instances = self.build(entries, stream.rule) - posts = duplicate_handler.check(instances) - - self.instances = [post for post in posts] + self.instances = duplicate_handler.check(instances) def build(self, entries, rule): field_mapping = { @@ -196,22 +194,27 @@ class FeedDuplicateHandler: def check(self, instances): deduplicated_instances = self.deduplicate_instances(instances) + checked_instances = [] for instance in deduplicated_instances: if instance.remote_identifier in self.existing_identifiers: existing_post = self.handle_duplicate_identifier(instance) - yield existing_post + checked_instances.append(existing_post) continue elif self.in_database(instance): existing_post = self.get_duplicate_in_database(instance) if self.in_time_slot(instance, existing_post): - yield self.update_existing_post(instance, existing_post) + checked_instances.append( + self.update_existing_post(instance, existing_post) + ) continue - yield instance + checked_instances.append(instance) + + return checked_instances def in_database(self, post): values = {field: getattr(post, field, None) for field in self.duplicate_fields} @@ -229,23 +232,29 @@ class FeedDuplicateHandler: return True def deduplicate_instances(self, instances): + sorted_instances = sorted( + instances, key=lambda instance: instance.publication_date, reverse=True + ) deduplicated_instances = [] - for instance in instances: + for instance in sorted_instances: + instance_identifier = instance.remote_identifier + duplicate = False + values = { field: getattr(instance, field, None) for field in self.duplicate_fields } - duplicate = False for deduplicated_instance in deduplicated_instances: deduplicated_identifier = deduplicated_instance.remote_identifier - instance_identifier = instance.remote_identifier has_identifiers = deduplicated_identifier and instance_identifier - if self.is_duplicate(deduplicated_instance, values): - duplicate = True - break - elif has_identifiers and deduplicated_identifier == instance_identifier: + is_same_identifier = ( + has_identifiers and deduplicated_identifier == instance_identifier + ) + is_duplicate = self.is_duplicate(deduplicated_instance, values) + + if is_duplicate or is_same_identifier: duplicate = True break diff --git a/src/newsreader/news/collection/tests/feed/builder/tests.py b/src/newsreader/news/collection/tests/feed/builder/tests.py index be13908..cfafa4f 100644 --- a/src/newsreader/news/collection/tests/feed/builder/tests.py +++ b/src/newsreader/news/collection/tests/feed/builder/tests.py @@ -16,6 +16,7 @@ from newsreader.news.core.tests.factories import PostFactory from .mocks import * +@freeze_time("2019-10-30 12:30:00") class FeedBuilderTestCase(TestCase): def setUp(self): self.maxDiff = None @@ -30,8 +31,10 @@ class FeedBuilderTestCase(TestCase): post = Post.objects.get() - d = datetime.combine(date(2019, 5, 20), time(hour=16, minute=7, second=37)) - aware_date = pytz.utc.localize(d) + publication_date = datetime.combine( + date(2019, 5, 20), time(hour=16, minute=7, second=37) + ) + aware_date = pytz.utc.localize(publication_date) self.assertEquals(post.publication_date, aware_date) self.assertEquals(Post.objects.count(), 1) @@ -57,49 +60,60 @@ class FeedBuilderTestCase(TestCase): with builder((multiple_mock, mock_stream)) as builder: builder.save() - posts = Post.objects.order_by("id") + posts = Post.objects.order_by("-publication_date") self.assertEquals(Post.objects.count(), 3) - first_post = posts[0] - second_post = posts[1] + post = posts[0] - d = datetime.combine(date(2019, 5, 20), time(hour=16, minute=7, second=37)) - aware_date = pytz.utc.localize(d) - - self.assertEquals(first_post.publication_date, aware_date) + publication_date = datetime.combine( + date(2019, 5, 20), time(hour=16, minute=32, second=38) + ) + aware_date = pytz.utc.localize(publication_date) self.assertEquals( - first_post.remote_identifier, + post.publication_date.strftime("%Y-%m-%d %H:%M:%S"), + aware_date.strftime("%Y-%m-%d %H:%M:%S"), + ) + + self.assertEquals( + post.remote_identifier, + "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + ) + + self.assertEquals( + post.url, "https://www.bbc.co.uk/news/uk-england-birmingham-48339080" + ) + + self.assertEquals( + post.title, "Birmingham head teacher threatened over LGBT lessons" + ) + + post = posts[1] + + publication_date = datetime.combine( + date(2019, 5, 20), time(hour=16, minute=7, second=37) + ) + aware_date = pytz.utc.localize(publication_date) + + self.assertEquals( + post.publication_date.strftime("%Y-%m-%d %H:%M:%S"), + aware_date.strftime("%Y-%m-%d %H:%M:%S"), + ) + + self.assertEquals( + post.remote_identifier, "https://www.bbc.co.uk/news/world-us-canada-48338168", ) self.assertEquals( - first_post.url, "https://www.bbc.co.uk/news/world-us-canada-48338168" + post.url, "https://www.bbc.co.uk/news/world-us-canada-48338168" ) self.assertEquals( - first_post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif" + post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif" ) - d = datetime.combine(date(2019, 5, 20), time(hour=12, minute=19, second=19)) - aware_date = pytz.utc.localize(d) - - self.assertEquals(second_post.publication_date, aware_date) - - self.assertEquals( - second_post.remote_identifier, - "https://www.bbc.co.uk/news/technology-48334739", - ) - - self.assertEquals( - second_post.url, "https://www.bbc.co.uk/news/technology-48334739" - ) - - self.assertEquals( - second_post.title, "Huawei's Android loss: How it affects you" - ) - - def test_entry_without_remote_identifier(self): + def test_entries_without_remote_identifier(self): builder = FeedBuilder rule = CollectionRuleFactory() mock_stream = MagicMock(rule=rule) @@ -107,27 +121,37 @@ class FeedBuilderTestCase(TestCase): with builder((mock_without_identifier, mock_stream)) as builder: builder.save() - posts = Post.objects.order_by("id") + posts = Post.objects.order_by("-publication_date") self.assertEquals(Post.objects.count(), 2) - first_post = posts[0] + post = posts[0] - d = datetime.combine(date(2019, 5, 20), time(hour=16, minute=7, second=37)) - aware_date = pytz.utc.localize(d) - - self.assertEquals(first_post.publication_date, aware_date) - - self.assertEquals(first_post.remote_identifier, None) + publication_date = datetime.combine( + date(2019, 5, 20), time(hour=16, minute=7, second=37) + ) + aware_date = pytz.utc.localize(publication_date) + self.assertEquals(post.publication_date, aware_date) + self.assertEquals(post.remote_identifier, None) self.assertEquals( - first_post.url, "https://www.bbc.co.uk/news/world-us-canada-48338168" + post.url, "https://www.bbc.co.uk/news/world-us-canada-48338168" + ) + self.assertEquals( + post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif" ) - self.assertEquals( - first_post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif" - ) + post = posts[1] + + publication_date = datetime.combine( + date(2019, 5, 20), time(hour=12, minute=19, second=19) + ) + aware_date = pytz.utc.localize(publication_date) + + self.assertEquals(post.publication_date, aware_date) + self.assertEquals(post.remote_identifier, None) + self.assertEquals(post.url, "https://www.bbc.co.uk/news/technology-48334739") + self.assertEquals(post.title, "Huawei's Android loss: How it affects you") - @freeze_time("2019-10-30 12:30:00") def test_entry_without_publication_date(self): builder = FeedBuilder rule = CollectionRuleFactory() @@ -136,25 +160,30 @@ class FeedBuilderTestCase(TestCase): with builder((mock_without_publish_date, mock_stream)) as builder: builder.save() - posts = Post.objects.order_by("id") + posts = Post.objects.order_by("-publication_date") self.assertEquals(Post.objects.count(), 2) - first_post = posts[0] - second_post = posts[1] + post = posts[0] - self.assertEquals(first_post.created, timezone.now()) self.assertEquals( - first_post.remote_identifier, + post.publication_date.strftime("%Y-%m-%d %H:%M"), "2019-10-30 12:30" + ) + self.assertEquals(post.created, timezone.now()) + self.assertEquals( + post.remote_identifier, "https://www.bbc.co.uk/news/world-us-canada-48338168", ) - self.assertEquals(second_post.created, timezone.now()) + post = posts[1] + self.assertEquals( - second_post.remote_identifier, - "https://www.bbc.co.uk/news/technology-48334739", + post.publication_date.strftime("%Y-%m-%d %H:%M"), "2019-10-30 12:30" + ) + self.assertEquals(post.created, timezone.now()) + self.assertEquals( + post.remote_identifier, "https://www.bbc.co.uk/news/technology-48334739" ) - @freeze_time("2019-10-30 12:30:00") def test_entry_without_url(self): builder = FeedBuilder rule = CollectionRuleFactory() @@ -163,25 +192,24 @@ class FeedBuilderTestCase(TestCase): with builder((mock_without_url, mock_stream)) as builder: builder.save() - posts = Post.objects.order_by("id") + posts = Post.objects.order_by("-publication_date") self.assertEquals(Post.objects.count(), 2) - first_post = posts[0] - second_post = posts[1] + post = posts[0] - self.assertEquals(first_post.created, timezone.now()) + self.assertEquals(post.created, timezone.now()) self.assertEquals( - first_post.remote_identifier, + post.remote_identifier, "https://www.bbc.co.uk/news/world-us-canada-48338168", ) - self.assertEquals(second_post.created, timezone.now()) + post = posts[1] + + self.assertEquals(post.created, timezone.now()) self.assertEquals( - second_post.remote_identifier, - "https://www.bbc.co.uk/news/technology-48334739", + post.remote_identifier, "https://www.bbc.co.uk/news/technology-48334739" ) - @freeze_time("2019-10-30 12:30:00") def test_entry_without_body(self): builder = FeedBuilder rule = CollectionRuleFactory() @@ -190,25 +218,32 @@ class FeedBuilderTestCase(TestCase): with builder((mock_without_body, mock_stream)) as builder: builder.save() - posts = Post.objects.order_by("id") + posts = Post.objects.order_by("-publication_date") + self.assertEquals(Post.objects.count(), 2) - first_post = posts[0] - second_post = posts[1] + post = posts[0] - self.assertEquals(first_post.created, timezone.now()) self.assertEquals( - first_post.remote_identifier, - "https://www.bbc.co.uk/news/world-us-canada-48338168", + post.created.strftime("%Y-%m-%d %H:%M:%S"), "2019-10-30 12:30:00" ) - - self.assertEquals(second_post.created, timezone.now()) self.assertEquals( - second_post.remote_identifier, + post.remote_identifier, "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", ) + self.assertEquals(post.body, "") + + post = posts[1] + + self.assertEquals( + post.created.strftime("%Y-%m-%d %H:%M:%S"), "2019-10-30 12:30:00" + ) + self.assertEquals( + post.remote_identifier, + "https://www.bbc.co.uk/news/world-us-canada-48338168", + ) + self.assertEquals(post.body, "") - @freeze_time("2019-10-30 12:30:00") def test_entry_without_author(self): builder = FeedBuilder rule = CollectionRuleFactory() @@ -217,23 +252,25 @@ class FeedBuilderTestCase(TestCase): with builder((mock_without_author, mock_stream)) as builder: builder.save() - posts = Post.objects.order_by("id") + posts = Post.objects.order_by("-publication_date") self.assertEquals(Post.objects.count(), 2) - first_post = posts[0] - second_post = posts[1] + post = posts[0] - self.assertEquals(first_post.created, timezone.now()) + self.assertEquals(post.created, timezone.now()) self.assertEquals( - first_post.remote_identifier, + post.remote_identifier, "https://www.bbc.co.uk/news/world-us-canada-48338168", ) + self.assertEquals(post.author, None) - self.assertEquals(second_post.created, timezone.now()) + post = posts[1] + + self.assertEquals(post.created, timezone.now()) self.assertEquals( - second_post.remote_identifier, - "https://www.bbc.co.uk/news/technology-48334739", + post.remote_identifier, "https://www.bbc.co.uk/news/technology-48334739" ) + self.assertEquals(post.author, None) def test_empty_entries(self): builder = FeedBuilder diff --git a/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py index 6ed8a59..109491b 100644 --- a/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py +++ b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py @@ -11,110 +11,137 @@ from newsreader.news.core.models import Post from newsreader.news.core.tests.factories import PostFactory +@freeze_time("2019-10-30 12:30:00") class FeedDuplicateHandlerTestCase(TestCase): def setUp(self): self.maxDiff = None def test_duplicate_entries_with_remote_identifiers(self): rule = CollectionRuleFactory() + existing_post = PostFactory.create( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", rule=rule ) - new_post = PostFactory.build( + + new_posts = PostFactory.build_batch( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", - title="title got updated", + publication_date=timezone.now() - timedelta(days=7), + rule=rule, + size=5, + ) + last_post = PostFactory.build( + remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", + publication_date=timezone.now(), rule=rule, ) with FeedDuplicateHandler(rule) as duplicate_handler: - posts_gen = duplicate_handler.check([new_post]) - posts = list(posts_gen) + posts = duplicate_handler.check((*new_posts, last_post)) self.assertEquals(len(posts), 1) post = posts[0] - existing_post.refresh_from_db() - self.assertEquals(existing_post.pk, post.pk) - self.assertEquals(post.publication_date, new_post.publication_date) - self.assertEquals(post.title, new_post.title) - self.assertEquals(post.body, new_post.body) - self.assertEquals(post.rule, new_post.rule) + self.assertEquals( + post.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + last_post.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + ) + self.assertEquals(post.title, last_post.title) + self.assertEquals(post.body, last_post.body) + self.assertEquals(post.rule, last_post.rule) self.assertEquals(post.read, False) - @freeze_time("2019-10-30 12:30:00") def test_duplicate_entries_with_different_remote_identifiers(self): rule = CollectionRuleFactory() - publication_date = timezone.now() - existing_post = PostFactory.create( + existing_post = PostFactory( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", url="https://bbc.com", title="New post", body="Body", - publication_date=publication_date, + publication_date=timezone.now() - timedelta(minutes=10), rule=rule, ) - new_post = PostFactory.build( + + new_posts = PostFactory.build_batch( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7Q", url="https://bbc.com", title="New post", body="Body", - publication_date=publication_date, + publication_date=timezone.now() - timedelta(minutes=5), + rule=rule, + size=5, + ) + last_post = PostFactory.build( + remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7Q", + url="https://bbc.com", + title="New post", + body="Body", + publication_date=timezone.now(), rule=rule, ) with FeedDuplicateHandler(rule) as duplicate_handler: - posts_gen = duplicate_handler.check([new_post]) - posts = list(posts_gen) + posts = duplicate_handler.check((*new_posts, last_post)) self.assertEquals(len(posts), 1) - existing_post.refresh_from_db() post = posts[0] - self.assertEquals(existing_post.pk, post.pk) - self.assertEquals(post.title, new_post.title) - self.assertEquals(post.body, new_post.body) - self.assertEquals(post.rule, new_post.rule) - self.assertEquals(post.publication_date, new_post.publication_date) + self.assertEquals( + post.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + last_post.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + ) + self.assertEquals(post.title, last_post.title) + self.assertEquals(post.body, last_post.body) + self.assertEquals(post.rule, last_post.rule) self.assertEquals(post.read, False) def test_duplicate_entries_in_recent_database(self): - publication_date = timezone.now() - rule = CollectionRuleFactory() - existing_post = PostFactory.create( + + existing_post = PostFactory( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", title="Birmingham head teacher threatened over LGBT lessons", body="Google's move to end business ties with Huawei will affect current devices", - publication_date=publication_date, - remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", + publication_date=timezone.now() - timedelta(minutes=10), + remote_identifier=None, rule=rule, ) - new_post = PostFactory.build( + + new_posts = PostFactory.build_batch( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", title="Birmingham head teacher threatened over LGBT lessons", body="Google's move to end business ties with Huawei will affect current devices", - publication_date=publication_date, + publication_date=timezone.now() - timedelta(minutes=5), + remote_identifier=None, + rule=rule, + size=5, + ) + + last_post = PostFactory.build( + url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + title="Birmingham head teacher threatened over LGBT lessons", + body="Google's move to end business ties with Huawei will affect current devices", + publication_date=timezone.now(), remote_identifier=None, rule=rule, ) with FeedDuplicateHandler(rule) as duplicate_handler: - posts_gen = duplicate_handler.check([new_post]) - posts = list(posts_gen) + posts = duplicate_handler.check((*new_posts, last_post)) self.assertEquals(len(posts), 1) - existing_post.refresh_from_db() post = posts[0] - self.assertEquals(existing_post.pk, post.pk) - self.assertEquals(post.title, new_post.title) - self.assertEquals(post.body, new_post.body) - self.assertEquals(post.rule, new_post.rule) - self.assertEquals(post.publication_date, new_post.publication_date) + self.assertEquals( + post.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + last_post.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + ) + self.assertEquals(post.title, last_post.title) + self.assertEquals(post.body, last_post.body) + self.assertEquals(post.rule, last_post.rule) self.assertEquals(post.read, False) def test_multiple_existing_entries_with_identifier(self): @@ -124,15 +151,20 @@ class FeedDuplicateHandlerTestCase(TestCase): remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", rule=rule, size=5 ) - new_post = PostFactory.build( + new_posts = PostFactory.build_batch( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", - title="This is a new one", + publication_date=timezone.now() - timedelta(hours=5), + rule=rule, + size=5, + ) + last_post = PostFactory.build( + remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", + publication_date=timezone.now() - timedelta(minutes=5), rule=rule, ) with FeedDuplicateHandler(rule) as duplicate_handler: - posts_gen = duplicate_handler.check([new_post]) - posts = list(posts_gen) + posts = duplicate_handler.check((*new_posts, last_post)) self.assertEquals(len(posts), 1) @@ -145,77 +177,101 @@ class FeedDuplicateHandlerTestCase(TestCase): post = posts[0] - self.assertEquals(post.title, new_post.title) - self.assertEquals(post.body, new_post.body) - self.assertEquals(post.publication_date, new_post.publication_date) - self.assertEquals(post.rule, new_post.rule) + self.assertEquals( + post.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + last_post.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + ) + self.assertEquals(post.title, last_post.title) + self.assertEquals(post.body, last_post.body) + self.assertEquals(post.rule, last_post.rule) self.assertEquals(post.read, False) - @freeze_time("2019-10-30 12:30:00") def test_duplicate_entries_outside_time_slot(self): - publication_date = timezone.now() - rule = CollectionRuleFactory() - existing_post = PostFactory.create( + + existing_post = PostFactory( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", title="Birmingham head teacher threatened over LGBT lessons", body="Google's move to end business ties with Huawei will affect current devices", - publication_date=publication_date, - remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", + publication_date=timezone.now(), + remote_identifier=None, rule=rule, ) - new_post = PostFactory.build( + + new_posts = PostFactory.build_batch( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", title="Birmingham head teacher threatened over LGBT lessons", body="Google's move to end business ties with Huawei will affect current devices", - publication_date=publication_date + timedelta(minutes=12), + publication_date=timezone.now() + timedelta(minutes=12), + remote_identifier=None, + rule=rule, + size=5, + ) + last_post = PostFactory.build( + url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", + title="Birmingham head teacher threatened over LGBT lessons", + body="Google's move to end business ties with Huawei will affect current devices", + publication_date=timezone.now() + timedelta(minutes=13), remote_identifier=None, rule=rule, ) with FeedDuplicateHandler(rule) as duplicate_handler: - posts_gen = duplicate_handler.check([new_post]) - posts = list(posts_gen) + posts = duplicate_handler.check((*new_posts, last_post)) self.assertEquals(len(posts), 1) - existing_post.refresh_from_db() post = posts[0] self.assertEquals(post.pk, None) - self.assertEquals(post.title, new_post.title) - self.assertEquals(post.body, new_post.body) - self.assertEquals(post.rule, new_post.rule) - self.assertEquals(post.publication_date, new_post.publication_date) + self.assertEquals( + post.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + last_post.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + ) + self.assertEquals(post.title, last_post.title) + self.assertEquals(post.body, last_post.body) + self.assertEquals(post.rule, last_post.rule) self.assertEquals(post.read, False) def test_duplicate_entries_in_collected_entries(self): rule = CollectionRuleFactory() post_1 = PostFactory.build( - title="title got updated", body="body", url="https://bbc.com", rule=rule + title="title got updated", + body="body", + url="https://bbc.com", + publication_date=timezone.now(), + rule=rule, ) duplicate_post_1 = PostFactory.build( - title="title got updated", body="body", url="https://bbc.com", rule=rule + title="title got updated", + body="body", + url="https://bbc.com", + publication_date=timezone.now() - timedelta(minutes=5), + rule=rule, ) post_2 = PostFactory.build( - remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7" + remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", + publication_date=timezone.now(), ) duplicate_post_2 = PostFactory.build( - remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7" + remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", + publication_date=timezone.now() - timedelta(minutes=5), ) collected_posts = (post_1, post_2, duplicate_post_1, duplicate_post_2) with FeedDuplicateHandler(rule) as duplicate_handler: - posts_gen = duplicate_handler.check(collected_posts) - posts = list(posts_gen) + posts = duplicate_handler.check(collected_posts) self.assertEquals(len(posts), 2) post = posts[0] - self.assertEquals(post_1.publication_date, post.publication_date) + self.assertEquals( + post_1.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + post.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + ) self.assertEquals(post_1.title, post.title) self.assertEquals(post_1.body, post.body) self.assertEquals(post_1.rule, post.rule) @@ -223,7 +279,10 @@ class FeedDuplicateHandlerTestCase(TestCase): post = posts[1] - self.assertEquals(post_2.publication_date, post.publication_date) + self.assertEquals( + post_2.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + post.publication_date.strftime("%Y-%m-%d %H-%M-%S"), + ) self.assertEquals(post_2.title, post.title) self.assertEquals(post_2.body, post.body) self.assertEquals(post_2.rule, post.rule) diff --git a/src/newsreader/news/core/models.py b/src/newsreader/news/core/models.py index 28bf3fd..ff44c81 100644 --- a/src/newsreader/news/core/models.py +++ b/src/newsreader/news/core/models.py @@ -8,7 +8,7 @@ from newsreader.news.collection.models import CollectionRule class Post(TimeStampedModel): title = models.CharField(max_length=200, blank=True, null=True) - body = models.TextField(blank=True, null=True) + body = models.TextField(blank=True) author = models.CharField(max_length=40, blank=True, null=True) publication_date = models.DateTimeField(default=timezone.now) url = models.URLField(max_length=1024, blank=True, null=True) From 6661b69094758da9aad1fcf2b876e35044946792 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 28 Jun 2020 20:35:58 +0200 Subject: [PATCH 109/422] Optionally load sentry --- poetry.lock | 32 ++++++++++++++++++++++++++++++- pyproject.toml | 1 + src/newsreader/conf/production.py | 13 +++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 8cad374..cab45d1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -669,6 +669,32 @@ optional = false python-versions = "*" version = "0.2.0" +[[package]] +category = "main" +description = "Python client for Sentry (https://getsentry.com)" +name = "sentry-sdk" +optional = false +python-versions = "*" +version = "0.15.1" + +[package.dependencies] +certifi = "*" +urllib3 = ">=1.10.0" + +[package.extras] +aiohttp = ["aiohttp (>=3.5)"] +beam = ["beam (>=2.12)"] +bottle = ["bottle (>=0.12.13)"] +celery = ["celery (>=3)"] +django = ["django (>=1.8)"] +falcon = ["falcon (>=1.4)"] +flask = ["flask (>=0.11)", "blinker (>=1.1)"] +pyspark = ["pyspark (>=2.4.4)"] +rq = ["rq (>=0.6)"] +sanic = ["sanic (>=0.8)"] +sqlalchemy = ["sqlalchemy (>=1.2)"] +tornado = ["tornado (>=5)"] + [[package]] category = "main" description = "Python 2 and 3 compatibility utilities" @@ -768,7 +794,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] [metadata] -content-hash = "479ed51fef7eb2990163f57ff4255887cd559deea9bb631fd8e3ca81e6939715" +content-hash = "6b207d452b10de2399c4c49118da997dda6ed1bb0437963c3f415ecd3d806fe5" python-versions = "^3.7" [metadata.files] @@ -1112,6 +1138,10 @@ requests = [ {file = "ruamel.yaml.clib-0.2.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:be018933c2f4ee7de55e7bd7d0d801b3dfb09d21dad0cce8a97995fd3e44be30"}, {file = "ruamel.yaml.clib-0.2.0.tar.gz", hash = "sha256:b66832ea8077d9b3f6e311c4a53d06273db5dc2db6e8a908550f3c14d67e718c"}, ] +sentry-sdk = [ + {file = "sentry-sdk-0.15.1.tar.gz", hash = "sha256:3ac0c430761b3cb7682ce612151d829f8644bb3830d4e530c75b02ceb745ff49"}, + {file = "sentry_sdk-0.15.1-py2.py3-none-any.whl", hash = "sha256:06825c15a78934e78941ea25910db71314c891608a46492fc32c15902c6b2119"}, +] six = [ {file = "six-1.14.0-py2.py3-none-any.whl", hash = "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"}, {file = "six-1.14.0.tar.gz", hash = "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a"}, diff --git a/pyproject.toml b/pyproject.toml index 71b5efc..bdc34a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ psycopg2-binary = "^2.8.5" gunicorn = "^20.0.4" python-dotenv = "^0.12.0" django = ">=3.0.7" +sentry-sdk = "^0.15.1" [tool.poetry.dev-dependencies] factory-boy = "^2.12.0" diff --git a/src/newsreader/conf/production.py b/src/newsreader/conf/production.py index b5c766a..7e883f7 100644 --- a/src/newsreader/conf/production.py +++ b/src/newsreader/conf/production.py @@ -49,3 +49,16 @@ TEMPLATES = [ AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler" REGISTRATION_OPEN = False + +# Optionally use sentry integration +try: + from sentry_sdk import init as sentry_init + from sentry_sdk.integrations.django import CeleryIntegration, DjangoIntegration + + sentry_init( + dsn=os.environ.get("SENTRY_DSN"), + integrations=[DjangoIntegration(), CeleryIntegration()], + send_default_pii=False, + ) +except ImportError: + pass From 1993338120072f54140440fba65581d3844df769 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 28 Jun 2020 20:42:09 +0200 Subject: [PATCH 110/422] 0.2.3.7 - Add optional sentry integration --- poetry.lock | 32 ++++++++++++++++++++++++++++++- pyproject.toml | 1 + src/newsreader/conf/production.py | 13 +++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 8cad374..cab45d1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -669,6 +669,32 @@ optional = false python-versions = "*" version = "0.2.0" +[[package]] +category = "main" +description = "Python client for Sentry (https://getsentry.com)" +name = "sentry-sdk" +optional = false +python-versions = "*" +version = "0.15.1" + +[package.dependencies] +certifi = "*" +urllib3 = ">=1.10.0" + +[package.extras] +aiohttp = ["aiohttp (>=3.5)"] +beam = ["beam (>=2.12)"] +bottle = ["bottle (>=0.12.13)"] +celery = ["celery (>=3)"] +django = ["django (>=1.8)"] +falcon = ["falcon (>=1.4)"] +flask = ["flask (>=0.11)", "blinker (>=1.1)"] +pyspark = ["pyspark (>=2.4.4)"] +rq = ["rq (>=0.6)"] +sanic = ["sanic (>=0.8)"] +sqlalchemy = ["sqlalchemy (>=1.2)"] +tornado = ["tornado (>=5)"] + [[package]] category = "main" description = "Python 2 and 3 compatibility utilities" @@ -768,7 +794,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] [metadata] -content-hash = "479ed51fef7eb2990163f57ff4255887cd559deea9bb631fd8e3ca81e6939715" +content-hash = "6b207d452b10de2399c4c49118da997dda6ed1bb0437963c3f415ecd3d806fe5" python-versions = "^3.7" [metadata.files] @@ -1112,6 +1138,10 @@ requests = [ {file = "ruamel.yaml.clib-0.2.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:be018933c2f4ee7de55e7bd7d0d801b3dfb09d21dad0cce8a97995fd3e44be30"}, {file = "ruamel.yaml.clib-0.2.0.tar.gz", hash = "sha256:b66832ea8077d9b3f6e311c4a53d06273db5dc2db6e8a908550f3c14d67e718c"}, ] +sentry-sdk = [ + {file = "sentry-sdk-0.15.1.tar.gz", hash = "sha256:3ac0c430761b3cb7682ce612151d829f8644bb3830d4e530c75b02ceb745ff49"}, + {file = "sentry_sdk-0.15.1-py2.py3-none-any.whl", hash = "sha256:06825c15a78934e78941ea25910db71314c891608a46492fc32c15902c6b2119"}, +] six = [ {file = "six-1.14.0-py2.py3-none-any.whl", hash = "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"}, {file = "six-1.14.0.tar.gz", hash = "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a"}, diff --git a/pyproject.toml b/pyproject.toml index 71b5efc..bdc34a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ psycopg2-binary = "^2.8.5" gunicorn = "^20.0.4" python-dotenv = "^0.12.0" django = ">=3.0.7" +sentry-sdk = "^0.15.1" [tool.poetry.dev-dependencies] factory-boy = "^2.12.0" diff --git a/src/newsreader/conf/production.py b/src/newsreader/conf/production.py index b5c766a..7e883f7 100644 --- a/src/newsreader/conf/production.py +++ b/src/newsreader/conf/production.py @@ -49,3 +49,16 @@ TEMPLATES = [ AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler" REGISTRATION_OPEN = False + +# Optionally use sentry integration +try: + from sentry_sdk import init as sentry_init + from sentry_sdk.integrations.django import CeleryIntegration, DjangoIntegration + + sentry_init( + dsn=os.environ.get("SENTRY_DSN"), + integrations=[DjangoIntegration(), CeleryIntegration()], + send_default_pii=False, + ) +except ImportError: + pass From 73ddb785e0de2269f601f66c6398de3f8acfdc45 Mon Sep 17 00:00:00 2001 From: Sonny Date: Mon, 29 Jun 2020 20:42:58 +0200 Subject: [PATCH 111/422] Update logging --- src/newsreader/conf/base.py | 20 ++++++++++---------- src/newsreader/conf/production.py | 7 +++++-- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 85a62ba..62008a3 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -103,13 +103,13 @@ CACHES = { # https://docs.djangoproject.com/en/2.2/topics/logging/#configuring-logging LOGGING = { "version": 1, - "disable_existing_loggers": True, + "disable_existing_loggers": False, "filters": { "require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}, "require_debug_true": {"()": "django.utils.log.RequireDebugTrue"}, }, "formatters": { - "django.server": { + "timestamped": { "()": "django.utils.log.ServerFormatter", "format": "[{server_time}] {message}", "style": "{", @@ -125,12 +125,7 @@ LOGGING = { "level": "INFO", "filters": ["require_debug_true"], "class": "logging.StreamHandler", - }, - "django.server": { - "level": "INFO", - "filters": ["require_debug_true"], - "class": "logging.StreamHandler", - "formatter": "django.server", + "formatter": "timestamped", }, "mail_admins": { "level": "ERROR", @@ -155,10 +150,15 @@ LOGGING = { "loggers": { "django": { "handlers": ["console", "mail_admins", "syslog_errors"], - "level": "INFO", + "level": "WARNING", }, "django.server": { - "handlers": ["django.server"], + "handlers": ["console", "syslog_errors"], + "level": "INFO", + "propagate": False, + }, + "django.request": { + "handlers": ["console", "syslog_errors"], "level": "INFO", "propagate": False, }, diff --git a/src/newsreader/conf/production.py b/src/newsreader/conf/production.py index 7e883f7..21a8d0f 100644 --- a/src/newsreader/conf/production.py +++ b/src/newsreader/conf/production.py @@ -53,11 +53,14 @@ REGISTRATION_OPEN = False # Optionally use sentry integration try: from sentry_sdk import init as sentry_init - from sentry_sdk.integrations.django import CeleryIntegration, DjangoIntegration + from sentry_sdk import integrations sentry_init( dsn=os.environ.get("SENTRY_DSN"), - integrations=[DjangoIntegration(), CeleryIntegration()], + integrations=[ + integrations.django.DjangoIntegration(), + integration.celery.CeleryIntegration(), + ], send_default_pii=False, ) except ImportError: From fcf49fa1232d4745388a6634bc34f5dd64d3fb0b Mon Sep 17 00:00:00 2001 From: Sonny Date: Mon, 29 Jun 2020 20:54:46 +0200 Subject: [PATCH 112/422] Update logging calls --- src/newsreader/news/collection/feed.py | 6 ++++-- src/newsreader/news/collection/tasks.py | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index 35b0b1e..e237713 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -162,6 +162,8 @@ class FeedClient(Client): yield response_data except StreamException as e: + logger.exception("Request failed") + length = stream.rule._meta.get_field("error").max_length stream.rule.error = e.message[-length:] stream.rule.succeeded = False @@ -277,7 +279,7 @@ class FeedDuplicateHandler: remote_identifier=instance.remote_identifier ) except ObjectDoesNotExist: - logger.error( + logger.exception( f"Duplicate handler tried retrieving post {instance.remote_identifier} but failed doing so." ) return instance @@ -300,7 +302,7 @@ class FeedDuplicateHandler: try: existing_post = self.queryset.get(**query_values) except ObjectDoesNotExist: - logger.error( + logger.exception( f"Duplicate handler tried retrieving post {instance.remote_identifier} but failed doing so." ) return instance diff --git a/src/newsreader/news/collection/tasks.py b/src/newsreader/news/collection/tasks.py index 6888cba..c02953e 100644 --- a/src/newsreader/news/collection/tasks.py +++ b/src/newsreader/news/collection/tasks.py @@ -34,7 +34,9 @@ class FeedTask(app.Task): collector = FeedCollector() collector.collect(rules=rules) else: - logger.info(f"Cancelling task due to existing lock for user {user_pk}") + logger.warning( + f"Cancelling task due to existing lock for user {user_pk}" + ) raise Reject(reason="Task already running", requeue=False) From 2254f22023313d6cd280f0ed7d426ae0023d2f77 Mon Sep 17 00:00:00 2001 From: Sonny Date: Mon, 29 Jun 2020 20:57:28 +0200 Subject: [PATCH 113/422] 0.2.3.8 -Update logging configuration -Fix sentry import error --- src/newsreader/conf/base.py | 20 ++++++++++---------- src/newsreader/conf/production.py | 7 +++++-- src/newsreader/news/collection/feed.py | 6 ++++-- src/newsreader/news/collection/tasks.py | 4 +++- 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 85a62ba..62008a3 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -103,13 +103,13 @@ CACHES = { # https://docs.djangoproject.com/en/2.2/topics/logging/#configuring-logging LOGGING = { "version": 1, - "disable_existing_loggers": True, + "disable_existing_loggers": False, "filters": { "require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}, "require_debug_true": {"()": "django.utils.log.RequireDebugTrue"}, }, "formatters": { - "django.server": { + "timestamped": { "()": "django.utils.log.ServerFormatter", "format": "[{server_time}] {message}", "style": "{", @@ -125,12 +125,7 @@ LOGGING = { "level": "INFO", "filters": ["require_debug_true"], "class": "logging.StreamHandler", - }, - "django.server": { - "level": "INFO", - "filters": ["require_debug_true"], - "class": "logging.StreamHandler", - "formatter": "django.server", + "formatter": "timestamped", }, "mail_admins": { "level": "ERROR", @@ -155,10 +150,15 @@ LOGGING = { "loggers": { "django": { "handlers": ["console", "mail_admins", "syslog_errors"], - "level": "INFO", + "level": "WARNING", }, "django.server": { - "handlers": ["django.server"], + "handlers": ["console", "syslog_errors"], + "level": "INFO", + "propagate": False, + }, + "django.request": { + "handlers": ["console", "syslog_errors"], "level": "INFO", "propagate": False, }, diff --git a/src/newsreader/conf/production.py b/src/newsreader/conf/production.py index 7e883f7..21a8d0f 100644 --- a/src/newsreader/conf/production.py +++ b/src/newsreader/conf/production.py @@ -53,11 +53,14 @@ REGISTRATION_OPEN = False # Optionally use sentry integration try: from sentry_sdk import init as sentry_init - from sentry_sdk.integrations.django import CeleryIntegration, DjangoIntegration + from sentry_sdk import integrations sentry_init( dsn=os.environ.get("SENTRY_DSN"), - integrations=[DjangoIntegration(), CeleryIntegration()], + integrations=[ + integrations.django.DjangoIntegration(), + integration.celery.CeleryIntegration(), + ], send_default_pii=False, ) except ImportError: diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index 35b0b1e..e237713 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -162,6 +162,8 @@ class FeedClient(Client): yield response_data except StreamException as e: + logger.exception("Request failed") + length = stream.rule._meta.get_field("error").max_length stream.rule.error = e.message[-length:] stream.rule.succeeded = False @@ -277,7 +279,7 @@ class FeedDuplicateHandler: remote_identifier=instance.remote_identifier ) except ObjectDoesNotExist: - logger.error( + logger.exception( f"Duplicate handler tried retrieving post {instance.remote_identifier} but failed doing so." ) return instance @@ -300,7 +302,7 @@ class FeedDuplicateHandler: try: existing_post = self.queryset.get(**query_values) except ObjectDoesNotExist: - logger.error( + logger.exception( f"Duplicate handler tried retrieving post {instance.remote_identifier} but failed doing so." ) return instance diff --git a/src/newsreader/news/collection/tasks.py b/src/newsreader/news/collection/tasks.py index 6888cba..c02953e 100644 --- a/src/newsreader/news/collection/tasks.py +++ b/src/newsreader/news/collection/tasks.py @@ -34,7 +34,9 @@ class FeedTask(app.Task): collector = FeedCollector() collector.collect(rules=rules) else: - logger.info(f"Cancelling task due to existing lock for user {user_pk}") + logger.warning( + f"Cancelling task due to existing lock for user {user_pk}" + ) raise Reject(reason="Task already running", requeue=False) From 04043fbe987e37e3ef9885d0d2927ffb4b646ca8 Mon Sep 17 00:00:00 2001 From: Sonny Date: Mon, 29 Jun 2020 21:06:38 +0200 Subject: [PATCH 114/422] 0.2.3.9 -Ugh this should be fixed now --- src/newsreader/conf/production.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/newsreader/conf/production.py b/src/newsreader/conf/production.py index 21a8d0f..4c2c480 100644 --- a/src/newsreader/conf/production.py +++ b/src/newsreader/conf/production.py @@ -53,14 +53,12 @@ REGISTRATION_OPEN = False # Optionally use sentry integration try: from sentry_sdk import init as sentry_init - from sentry_sdk import integrations + from sentry_sdk.integrations.celery import CeleryIntegration + from sentry_sdk.integrations.django import DjangoIntegration sentry_init( dsn=os.environ.get("SENTRY_DSN"), - integrations=[ - integrations.django.DjangoIntegration(), - integration.celery.CeleryIntegration(), - ], + integrations=[DjangoIntegration(), CeleryIntegration()], send_default_pii=False, ) except ImportError: From 03d673e6c0f9a92a82851f062d2aa66fe3c8feb5 Mon Sep 17 00:00:00 2001 From: sonny Date: Tue, 30 Jun 2020 20:27:17 +0200 Subject: [PATCH 115/422] Fixed task passing through disabled rules --- src/newsreader/news/collection/base.py | 2 +- src/newsreader/news/collection/models.py | 7 +++++++ src/newsreader/news/collection/tasks.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/newsreader/news/collection/base.py b/src/newsreader/news/collection/base.py index 7fcefff..710580f 100644 --- a/src/newsreader/news/collection/base.py +++ b/src/newsreader/news/collection/base.py @@ -23,7 +23,7 @@ class Client: stream = Stream def __init__(self, rules=None): - self.rules = rules if rules else CollectionRule.objects.filter(enabled=True) + self.rules = rules if rules else CollectionRule.objects.enabled() def __enter__(self): for rule in self.rules: diff --git a/src/newsreader/news/collection/models.py b/src/newsreader/news/collection/models.py index 3ab9c0d..cc22f8a 100644 --- a/src/newsreader/news/collection/models.py +++ b/src/newsreader/news/collection/models.py @@ -6,6 +6,11 @@ import pytz from newsreader.core.models import TimeStampedModel +class CollectionRuleQuerySet(models.QuerySet): + def enabled(self): + return self.filter(enabled=True) + + class CollectionRule(TimeStampedModel): name = models.CharField(max_length=100) @@ -45,5 +50,7 @@ class CollectionRule(TimeStampedModel): on_delete=models.CASCADE, ) + objects = CollectionRuleQuerySet.as_manager() + def __str__(self): return self.name diff --git a/src/newsreader/news/collection/tasks.py b/src/newsreader/news/collection/tasks.py index c02953e..dab94d4 100644 --- a/src/newsreader/news/collection/tasks.py +++ b/src/newsreader/news/collection/tasks.py @@ -29,7 +29,7 @@ class FeedTask(app.Task): if acquired: logger.info(f"Running task for user {user_pk}") - rules = user.rules.all() + rules = user.rules.enabled() collector = FeedCollector() collector.collect(rules=rules) From 391796a0c00af08606f9315b9665cfe4f1f1b70b Mon Sep 17 00:00:00 2001 From: sonny Date: Tue, 30 Jun 2020 22:57:49 +0200 Subject: [PATCH 116/422] 0.2.3.10 - Fixed tasks using disabled rules --- src/newsreader/news/collection/base.py | 2 +- src/newsreader/news/collection/models.py | 7 +++++++ src/newsreader/news/collection/tasks.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/newsreader/news/collection/base.py b/src/newsreader/news/collection/base.py index 7fcefff..710580f 100644 --- a/src/newsreader/news/collection/base.py +++ b/src/newsreader/news/collection/base.py @@ -23,7 +23,7 @@ class Client: stream = Stream def __init__(self, rules=None): - self.rules = rules if rules else CollectionRule.objects.filter(enabled=True) + self.rules = rules if rules else CollectionRule.objects.enabled() def __enter__(self): for rule in self.rules: diff --git a/src/newsreader/news/collection/models.py b/src/newsreader/news/collection/models.py index 3ab9c0d..cc22f8a 100644 --- a/src/newsreader/news/collection/models.py +++ b/src/newsreader/news/collection/models.py @@ -6,6 +6,11 @@ import pytz from newsreader.core.models import TimeStampedModel +class CollectionRuleQuerySet(models.QuerySet): + def enabled(self): + return self.filter(enabled=True) + + class CollectionRule(TimeStampedModel): name = models.CharField(max_length=100) @@ -45,5 +50,7 @@ class CollectionRule(TimeStampedModel): on_delete=models.CASCADE, ) + objects = CollectionRuleQuerySet.as_manager() + def __str__(self): return self.name diff --git a/src/newsreader/news/collection/tasks.py b/src/newsreader/news/collection/tasks.py index c02953e..dab94d4 100644 --- a/src/newsreader/news/collection/tasks.py +++ b/src/newsreader/news/collection/tasks.py @@ -29,7 +29,7 @@ class FeedTask(app.Task): if acquired: logger.info(f"Running task for user {user_pk}") - rules = user.rules.all() + rules = user.rules.enabled() collector = FeedCollector() collector.collect(rules=rules) From 6ce013d0d4a16a942dd4ce62b8930ae6235254f9 Mon Sep 17 00:00:00 2001 From: sonny Date: Sun, 12 Jul 2020 20:10:57 +0200 Subject: [PATCH 117/422] Add reddit integration --- docker-compose.yml | 4 + src/newsreader/accounts/admin.py | 21 +- .../migrations/0010_auto_20200603_2230.py | 21 + src/newsreader/accounts/models.py | 5 +- .../accounts/components/settings-form.html | 12 + .../templates/accounts/views/reddit.html | 17 + .../accounts/tests/test_settings.py | 161 + src/newsreader/accounts/tests/test_views.py | 29 - src/newsreader/accounts/urls.py | 12 + src/newsreader/accounts/views.py | 97 +- src/newsreader/conf/base.py | 18 +- src/newsreader/conf/production.py | 5 + src/newsreader/js/components/Card.js | 2 +- src/newsreader/news/collection/base.py | 24 +- src/newsreader/news/collection/choices.py | 7 + src/newsreader/news/collection/exceptions.py | 9 +- src/newsreader/news/collection/feed.py | 52 +- src/newsreader/news/collection/forms.py | 40 +- .../migrations/0008_collectionrule_type.py | 20 + src/newsreader/news/collection/models.py | 16 +- src/newsreader/news/collection/reddit.py | 307 ++ .../news/collection/response_handler.py | 16 +- src/newsreader/news/collection/tasks.py | 76 +- .../news/collection/views/rules.html | 3 +- .../collection/views/subreddit-create.html | 9 + .../collection/views/subreddit-update.html | 9 + .../news/collection/tests/factories.py | 11 + .../collection/tests/feed/builder/tests.py | 32 +- .../collection/tests/feed/client/tests.py | 52 +- .../collection/tests/feed/collector/tests.py | 23 +- .../tests/feed/duplicate_handler/tests.py | 14 +- .../collection/tests/feed/stream/mocks.py | 207 +- .../collection/tests/feed/stream/tests.py | 22 +- .../news/collection/tests/reddit/__init__.py | 0 .../tests/reddit/builder/__init__.py | 0 .../collection/tests/reddit/builder/mocks.py | 1378 +++++++ .../collection/tests/reddit/builder/tests.py | 185 + .../tests/reddit/client/__init__.py | 0 .../collection/tests/reddit/client/mocks.py | 160 + .../collection/tests/reddit/client/tests.py | 164 + .../tests/reddit/collector/__init__.py | 0 .../tests/reddit/collector/mocks.py | 1662 +++++++++ .../tests/reddit/collector/tests.py | 204 + .../tests/reddit/stream/__init__.py | 0 .../collection/tests/reddit/stream/mocks.py | 3289 +++++++++++++++++ .../collection/tests/reddit/stream/tests.py | 144 + .../collection/tests/reddit/test_scheduler.py | 142 + .../news/collection/tests/utils/tests.py | 77 +- .../news/collection/tests/views/__init__.py | 0 .../news/collection/tests/views/base.py | 69 + .../collection/tests/views/test_bulk_views.py | 24 +- .../news/collection/tests/views/test_crud.py | 89 +- .../tests/views/test_import_view.py | 38 +- .../tests/views/test_subreddit_views.py | 113 + src/newsreader/news/collection/urls.py | 12 + src/newsreader/news/collection/utils.py | 45 +- .../news/collection/views/__init__.py | 13 + src/newsreader/news/collection/views/base.py | 36 + .../news/collection/views/reddit.py | 26 + .../collection/{views.py => views/rules.py} | 55 +- .../migrations/0007_auto_20200706_2312.py | 17 + .../components/section/_text-section.scss | 1 - .../scss/elements/button/_button.scss | 9 + src/newsreader/scss/pages/settings/index.scss | 14 +- src/newsreader/scss/partials/_colors.scss | 2 + 65 files changed, 8949 insertions(+), 372 deletions(-) create mode 100644 src/newsreader/accounts/migrations/0010_auto_20200603_2230.py create mode 100644 src/newsreader/accounts/templates/accounts/views/reddit.html create mode 100644 src/newsreader/accounts/tests/test_settings.py delete mode 100644 src/newsreader/accounts/tests/test_views.py create mode 100644 src/newsreader/news/collection/choices.py create mode 100644 src/newsreader/news/collection/migrations/0008_collectionrule_type.py create mode 100644 src/newsreader/news/collection/reddit.py create mode 100644 src/newsreader/news/collection/templates/news/collection/views/subreddit-create.html create mode 100644 src/newsreader/news/collection/templates/news/collection/views/subreddit-update.html create mode 100644 src/newsreader/news/collection/tests/reddit/__init__.py create mode 100644 src/newsreader/news/collection/tests/reddit/builder/__init__.py create mode 100644 src/newsreader/news/collection/tests/reddit/builder/mocks.py create mode 100644 src/newsreader/news/collection/tests/reddit/builder/tests.py create mode 100644 src/newsreader/news/collection/tests/reddit/client/__init__.py create mode 100644 src/newsreader/news/collection/tests/reddit/client/mocks.py create mode 100644 src/newsreader/news/collection/tests/reddit/client/tests.py create mode 100644 src/newsreader/news/collection/tests/reddit/collector/__init__.py create mode 100644 src/newsreader/news/collection/tests/reddit/collector/mocks.py create mode 100644 src/newsreader/news/collection/tests/reddit/collector/tests.py create mode 100644 src/newsreader/news/collection/tests/reddit/stream/__init__.py create mode 100644 src/newsreader/news/collection/tests/reddit/stream/mocks.py create mode 100644 src/newsreader/news/collection/tests/reddit/stream/tests.py create mode 100644 src/newsreader/news/collection/tests/reddit/test_scheduler.py create mode 100644 src/newsreader/news/collection/tests/views/__init__.py create mode 100644 src/newsreader/news/collection/tests/views/base.py create mode 100644 src/newsreader/news/collection/tests/views/test_subreddit_views.py create mode 100644 src/newsreader/news/collection/views/__init__.py create mode 100644 src/newsreader/news/collection/views/base.py create mode 100644 src/newsreader/news/collection/views/reddit.py rename src/newsreader/news/collection/{views.py => views/rules.py} (72%) create mode 100644 src/newsreader/news/core/migrations/0007_auto_20200706_2312.py diff --git a/docker-compose.yml b/docker-compose.yml index 27d2969..c7dc5ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,8 @@ services: - DJANGO_SETTINGS_MODULE=newsreader.conf.docker depends_on: - rabbitmq + volumes: + - .:/app django: build: context: . @@ -45,6 +47,8 @@ services: volumes: - .:/app - static-files:/app/src/newsreader/static + stdin_open: true + tty: true webpack: build: context: . diff --git a/src/newsreader/accounts/admin.py b/src/newsreader/accounts/admin.py index c223687..e0b5eed 100644 --- a/src/newsreader/accounts/admin.py +++ b/src/newsreader/accounts/admin.py @@ -1,9 +1,19 @@ +from django import forms from django.contrib import admin from django.utils.translation import ugettext as _ from newsreader.accounts.models import User +class UserAdminForm(forms.ModelForm): + class Meta: + widgets = { + "email": forms.EmailInput(attrs={"size": "50"}), + "reddit_access_token": forms.TextInput(attrs={"size": "90"}), + "reddit_refresh_token": forms.TextInput(attrs={"size": "90"}), + } + + class UserAdmin(admin.ModelAdmin): list_display = ("email", "last_name", "date_joined", "is_active") list_filter = ("is_active", "is_staff", "is_superuser") @@ -11,17 +21,20 @@ class UserAdmin(admin.ModelAdmin): search_fields = ["email", "last_name", "first_name"] readonly_fields = ("last_login", "date_joined") + + form = UserAdminForm fieldsets = ( ( _("User settings"), {"fields": ("email", "first_name", "last_name", "is_active")}, ), + ( + _("Reddit settings"), + {"fields": ("reddit_access_token", "reddit_refresh_token")}, + ), ( _("Permission settings"), - { - "classes": ("collapse",), - "fields": ("is_staff", "is_superuser", "groups", "user_permissions"), - }, + {"classes": ("collapse",), "fields": ("is_staff", "is_superuser")}, ), (_("Misc settings"), {"fields": ("date_joined", "last_login")}), ) diff --git a/src/newsreader/accounts/migrations/0010_auto_20200603_2230.py b/src/newsreader/accounts/migrations/0010_auto_20200603_2230.py new file mode 100644 index 0000000..294ff31 --- /dev/null +++ b/src/newsreader/accounts/migrations/0010_auto_20200603_2230.py @@ -0,0 +1,21 @@ +# Generated by Django 3.0.5 on 2020-06-03 20:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("accounts", "0009_auto_20200524_1218")] + + operations = [ + migrations.AddField( + model_name="user", + name="reddit_access_token", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="user", + name="reddit_refresh_token", + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/src/newsreader/accounts/models.py b/src/newsreader/accounts/models.py index 18eba07..b8aaa64 100644 --- a/src/newsreader/accounts/models.py +++ b/src/newsreader/accounts/models.py @@ -50,6 +50,9 @@ class User(AbstractUser): verbose_name="collection task", ) + reddit_refresh_token = models.CharField(max_length=255, blank=True, null=True) + reddit_access_token = models.CharField(max_length=255, blank=True, null=True) + username = None objects = UserManager() @@ -69,7 +72,7 @@ class User(AbstractUser): enabled=True, interval=task_interval, name=f"{self.email}-collection-task", - task="newsreader.news.collection.tasks.FeedTask", + task="FeedTask", args=json.dumps([self.pk]), ) diff --git a/src/newsreader/accounts/templates/accounts/components/settings-form.html b/src/newsreader/accounts/templates/accounts/components/settings-form.html index ff06cb7..7942354 100644 --- a/src/newsreader/accounts/templates/accounts/components/settings-form.html +++ b/src/newsreader/accounts/templates/accounts/components/settings-form.html @@ -13,6 +13,18 @@ {% include "components/form/confirm-button.html" %} + + {% if reddit_authorization_url %} + + {% trans "Authorize Reddit account" %} + + {% endif %} + + {% if reddit_refresh_url %} + + {% trans "Refresh Reddit access token" %} + + {% endif %} {% endblock actions %} diff --git a/src/newsreader/accounts/templates/accounts/views/reddit.html b/src/newsreader/accounts/templates/accounts/views/reddit.html new file mode 100644 index 0000000..b393bbe --- /dev/null +++ b/src/newsreader/accounts/templates/accounts/views/reddit.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% block content %} +
      +
      + {% if error %} +

      Reddit authorization failed

      +

      {{ error }}

      + {% elif access_token and refresh_token %} +

      Reddit account is linked

      +

      Your reddit account was successfully linked.

      + {% endif %} + +

      Return to settings page

      +
      +
      +{% endblock %} diff --git a/src/newsreader/accounts/tests/test_settings.py b/src/newsreader/accounts/tests/test_settings.py new file mode 100644 index 0000000..d093ea4 --- /dev/null +++ b/src/newsreader/accounts/tests/test_settings.py @@ -0,0 +1,161 @@ +from unittest.mock import patch +from urllib.parse import urlencode +from uuid import uuid4 + +from django.core.cache import cache +from django.test import TestCase +from django.urls import reverse + +from newsreader.accounts.models import User +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.exceptions import StreamTooManyException + + +class SettingsViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(email="test@test.nl", password="test") + self.client.force_login(self.user) + + self.url = reverse("accounts:settings") + + def test_simple(self): + response = self.client.get(self.url) + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Authorize Reddit account") + + def test_user_credential_change(self): + response = self.client.post( + reverse("accounts:settings"), + {"first_name": "First name", "last_name": "Last name"}, + ) + + user = User.objects.get() + + self.assertRedirects(response, reverse("accounts:settings")) + + self.assertEquals(user.first_name, "First name") + self.assertEquals(user.last_name, "Last name") + + def test_linked_reddit_account(self): + self.user.reddit_refresh_token = "test" + self.user.save() + + response = self.client.get(self.url) + + self.assertEquals(response.status_code, 200) + self.assertNotContains(response, "Authorize Reddit account") + + +class RedditTemplateViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(email="test@test.nl", password="test") + self.client.force_login(self.user) + + self.base_url = reverse("accounts:reddit-template") + self.state = str(uuid4()) + + self.patch = patch("newsreader.news.collection.reddit.post") + self.mocked_post = self.patch.start() + + def tearDown(self): + patch.stopall() + + def test_simple(self): + response = self.client.get(self.base_url) + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Return to settings page") + + def test_successful_authorization(self): + self.mocked_post.return_value.json.return_value = { + "access_token": "1001010412", + "refresh_token": "134510143", + } + + cache.set(f"{self.user.email}-reddit-auth", self.state) + + params = {"state": self.state, "code": "Valid code"} + url = f"{self.base_url}?{urlencode(params)}" + + response = self.client.get(url) + + self.mocked_post.assert_called_once() + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Your reddit account was successfully linked.") + + self.user.refresh_from_db() + + self.assertEquals(self.user.reddit_access_token, "1001010412") + self.assertEquals(self.user.reddit_refresh_token, "134510143") + + self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), None) + + def test_error(self): + params = {"error": "Denied authorization"} + + url = f"{self.base_url}?{urlencode(params)}" + + response = self.client.get(url) + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Denied authorization") + + def test_invalid_state(self): + cache.set(f"{self.user.email}-reddit-auth", str(uuid4())) + + params = {"code": "Valid code", "state": "Invalid state"} + + url = f"{self.base_url}?{urlencode(params)}" + + response = self.client.get(url) + + self.assertEquals(response.status_code, 200) + self.assertContains( + response, "The saved state for Reddit authorization did not match" + ) + + def test_stream_error(self): + self.mocked_post.side_effect = StreamTooManyException + + cache.set(f"{self.user.email}-reddit-auth", self.state) + + params = {"state": self.state, "code": "Valid code"} + url = f"{self.base_url}?{urlencode(params)}" + + response = self.client.get(url) + + self.mocked_post.assert_called_once() + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Too many requests") + + self.user.refresh_from_db() + + self.assertEquals(self.user.reddit_access_token, None) + self.assertEquals(self.user.reddit_refresh_token, None) + + self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), self.state) + + def test_unexpected_json(self): + self.mocked_post.return_value.json.return_value = {"message": "Happy eastern"} + + cache.set(f"{self.user.email}-reddit-auth", self.state) + + params = {"state": self.state, "code": "Valid code"} + url = f"{self.base_url}?{urlencode(params)}" + + response = self.client.get(url) + + self.mocked_post.assert_called_once() + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Access and refresh token not found in response") + + self.user.refresh_from_db() + + self.assertEquals(self.user.reddit_access_token, None) + self.assertEquals(self.user.reddit_refresh_token, None) + + self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), self.state) diff --git a/src/newsreader/accounts/tests/test_views.py b/src/newsreader/accounts/tests/test_views.py deleted file mode 100644 index d3ac77c..0000000 --- a/src/newsreader/accounts/tests/test_views.py +++ /dev/null @@ -1,29 +0,0 @@ -from django.test import TestCase -from django.urls import reverse - -from newsreader.accounts.models import User -from newsreader.accounts.tests.factories import UserFactory - - -class UserSettingsViewTestCase(TestCase): - def setUp(self): - self.user = UserFactory(password="test") - self.client.force_login(self.user) - - def test_simple(self): - response = self.client.get(reverse("accounts:settings")) - - self.assertEquals(response.status_code, 200) - - def test_user_credential_change(self): - response = self.client.post( - reverse("accounts:settings"), - {"first_name": "First name", "last_name": "Last name"}, - ) - - user = User.objects.get() - - self.assertRedirects(response, reverse("accounts:settings")) - - self.assertEquals(user.first_name, "First name") - self.assertEquals(user.last_name, "Last name") diff --git a/src/newsreader/accounts/urls.py b/src/newsreader/accounts/urls.py index d42ae13..672cf6d 100644 --- a/src/newsreader/accounts/urls.py +++ b/src/newsreader/accounts/urls.py @@ -12,6 +12,8 @@ from newsreader.accounts.views import ( PasswordResetConfirmView, PasswordResetDoneView, PasswordResetView, + RedditTemplateView, + RedditTokenRedirectView, RegistrationClosedView, RegistrationCompleteView, RegistrationView, @@ -61,4 +63,14 @@ urlpatterns = [ name="password-change", ), path("settings/", login_required(SettingsView.as_view()), name="settings"), + path( + "settings/reddit/callback/", + login_required(RedditTemplateView.as_view()), + name="reddit-template", + ), + path( + "settings/reddit/refresh/", + login_required(RedditTokenRedirectView.as_view()), + name="reddit-refresh", + ), ] diff --git a/src/newsreader/accounts/views.py b/src/newsreader/accounts/views.py index fed60eb..4f982a9 100644 --- a/src/newsreader/accounts/views.py +++ b/src/newsreader/accounts/views.py @@ -1,13 +1,22 @@ +from django.contrib import messages from django.contrib.auth import views as django_views +from django.core.cache import cache from django.shortcuts import render from django.urls import reverse_lazy -from django.views.generic import TemplateView +from django.utils.translation import gettext as _ +from django.views.generic import RedirectView, TemplateView from django.views.generic.edit import FormView, ModelFormMixin from registration.backends.default import views as registration_views from newsreader.accounts.forms import UserSettingsForm from newsreader.accounts.models import User +from newsreader.news.collection.exceptions import StreamException +from newsreader.news.collection.reddit import ( + get_reddit_access_token, + get_reddit_authorization_url, +) +from newsreader.news.collection.tasks import RedditTokenTask class LoginView(django_views.LoginView): @@ -111,5 +120,91 @@ class SettingsView(ModelFormMixin, FormView): def get_object(self, **kwargs): return self.request.user + def get_context_data(self, **kwargs): + user = self.request.user + + reddit_authorization_url = None + reddit_refresh_url = None + reddit_task_active = cache.get(f"{user.email}-reddit-refresh") + + if ( + user.reddit_refresh_token + and not user.reddit_access_token + and not reddit_task_active + ): + reddit_refresh_url = reverse_lazy("accounts:reddit-refresh") + + if not user.reddit_refresh_token: + reddit_authorization_url = get_reddit_authorization_url(user) + + return { + **super().get_context_data(**kwargs), + "reddit_authorization_url": reddit_authorization_url, + "reddit_refresh_url": reddit_refresh_url, + } + def get_form_kwargs(self): return {**super().get_form_kwargs(), "instance": self.request.user} + + +class RedditTemplateView(TemplateView): + template_name = "accounts/views/reddit.html" + + def get(self, request, *args, **kwargs): + context = self.get_context_data(**kwargs) + + error = request.GET.get("error", None) + state = request.GET.get("state", None) + code = request.GET.get("code", None) + + if error: + return self.render_to_response({**context, "error": error}) + + if not code or not state: + return self.render_to_response(context) + + cached_state = cache.get(f"{request.user.email}-reddit-auth") + + if state != cached_state: + return self.render_to_response( + { + **context, + "error": "The saved state for Reddit authorization did not match", + } + ) + + try: + access_token, refresh_token = get_reddit_access_token(code, request.user) + + return self.render_to_response( + { + **context, + "access_token": access_token, + "refresh_token": refresh_token, + } + ) + except StreamException as e: + return self.render_to_response({**context, "error": str(e)}) + except KeyError: + return self.render_to_response( + {**context, "error": "Access and refresh token not found in response"} + ) + + +class RedditTokenRedirectView(RedirectView): + url = reverse_lazy("accounts:settings") + + def get(self, request, *args, **kwargs): + response = super().get(request, *args, **kwargs) + + user = request.user + task_active = cache.get(f"{user.email}-reddit-refresh") + + if not task_active: + RedditTokenTask.delay(user.pk) + messages.success(request, _("Access token is being retrieved")) + cache.set(f"{user.email}-reddit-refresh", 1, 300) + return response + + messages.error(request, _("Unable to retrieve token")) + return response diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 62008a3..b117b4f 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -11,8 +11,8 @@ DJANGO_PROJECT_DIR = os.path.join(BASE_DIR, "src", "newsreader") # SECURITY WARNING: don"t run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = ["127.0.0.1"] -INTERNAL_IPS = ["127.0.0.1"] +ALLOWED_HOSTS = ["127.0.0.1", "localhost"] +INTERNAL_IPS = ["127.0.0.1", "localhost"] # Application definition INSTALLED_APPS = [ @@ -162,7 +162,13 @@ LOGGING = { "level": "INFO", "propagate": False, }, - "celery.task": {"handlers": ["syslog", "console"], "level": "INFO"}, + "celery": {"handlers": ["syslog", "console"], "level": "INFO"}, + "celery.task": { + "handlers": ["syslog", "console"], + "level": "INFO", + "propagate": False, + }, + "newsreader": {"handlers": ["syslog", "console"], "level": "INFO"}, }, } @@ -205,6 +211,12 @@ STATICFILES_FINDERS = [ DEFAULT_FROM_EMAIL = "newsreader@rss.fudiggity.nl" +# Project settings +# Reddit integration +REDDIT_CLIENT_ID = "CLIENT_ID" +REDDIT_CLIENT_SECRET = "CLIENT_SECRET" +REDDIT_REDIRECT_URL = "http://127.0.0.1:8000/accounts/settings/reddit/callback/" + # Third party settings AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler" AXES_CACHE = "axes" diff --git a/src/newsreader/conf/production.py b/src/newsreader/conf/production.py index 4c2c480..0dee323 100644 --- a/src/newsreader/conf/production.py +++ b/src/newsreader/conf/production.py @@ -45,6 +45,11 @@ TEMPLATES = [ } ] +# Reddit integration +REDDIT_CLIENT_ID = os.environ["REDDIT_CLIENT_ID"] +REDDIT_CLIENT_SECRET = os.environ["REDDIT_CLIENT_SECRET"] +REDDIT_REDIRECT_URL = "https://rss.fudiggity.nl/settings/reddit/callback/" + # Third party settings AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler" diff --git a/src/newsreader/js/components/Card.js b/src/newsreader/js/components/Card.js index d1580a4..6346dcb 100644 --- a/src/newsreader/js/components/Card.js +++ b/src/newsreader/js/components/Card.js @@ -2,7 +2,7 @@ import React from 'react'; const Card = props => { return ( -
      +
      {props.header}
      {props.content}
      {props.footer}
      diff --git a/src/newsreader/news/collection/base.py b/src/newsreader/news/collection/base.py index 710580f..f980191 100644 --- a/src/newsreader/news/collection/base.py +++ b/src/newsreader/news/collection/base.py @@ -1,18 +1,23 @@ from bs4 import BeautifulSoup from newsreader.news.collection.exceptions import StreamParseException -from newsreader.news.collection.models import CollectionRule from newsreader.news.collection.utils import fetch class Stream: + """ + Contains the data and makes it available for processing + """ + + rule = None + def __init__(self, rule): self.rule = rule def read(self): raise NotImplementedError - def parse(self, payload): + def parse(self, response): raise NotImplementedError class Meta: @@ -20,9 +25,13 @@ class Stream: class Client: + """ + Retrieves the data with streams + """ + stream = Stream - def __init__(self, rules=None): + def __init__(self, rules=[]): self.rules = rules if rules else CollectionRule.objects.enabled() def __enter__(self): @@ -39,7 +48,12 @@ class Client: class Builder: + """ + Creates the collected posts + """ + instances = [] + stream = None def __init__(self, stream): self.stream = stream @@ -62,6 +76,10 @@ class Builder: class Collector: + """ + Glue between client, streams and builder + """ + client = None builder = None diff --git a/src/newsreader/news/collection/choices.py b/src/newsreader/news/collection/choices.py new file mode 100644 index 0000000..65f7ef5 --- /dev/null +++ b/src/newsreader/news/collection/choices.py @@ -0,0 +1,7 @@ +from django.db.models import TextChoices +from django.utils.translation import gettext as _ + + +class RuleTypeChoices(TextChoices): + feed = "feed", _("Feed") + subreddit = "subreddit", _("Subreddit") diff --git a/src/newsreader/news/collection/exceptions.py b/src/newsreader/news/collection/exceptions.py index e636638..e002b43 100644 --- a/src/newsreader/news/collection/exceptions.py +++ b/src/newsreader/news/collection/exceptions.py @@ -1,7 +1,8 @@ class StreamException(Exception): message = "Stream exception" - def __init__(self, message=None): + def __init__(self, response=None, message=None): + self.response = response self.message = message if message else self.message def __str__(self): @@ -28,5 +29,9 @@ class StreamParseException(StreamException): message = "Stream could not be parsed" -class StreamConnectionError(StreamException): +class StreamConnectionException(StreamException): message = "A connection to the stream could not be made" + + +class StreamTooManyException(StreamException): + message = "Too many requests" diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index e237713..8018bb5 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -4,8 +4,6 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import timedelta from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist -from django.db.models.fields import CharField, TextField -from django.template.defaultfilters import truncatechars from django.utils import timezone import bleach @@ -14,6 +12,7 @@ import pytz from feedparser import parse from newsreader.news.collection.base import Builder, Client, Collector, Stream +from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.constants import ( WHITELISTED_ATTRIBUTES, WHITELISTED_TAGS, @@ -25,7 +24,12 @@ from newsreader.news.collection.exceptions import ( StreamParseException, StreamTimeOutException, ) -from newsreader.news.collection.utils import build_publication_date, fetch +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.utils import ( + build_publication_date, + fetch, + truncate_text, +) from newsreader.news.core.models import Post @@ -37,10 +41,13 @@ class FeedBuilder(Builder): def __enter__(self): _, stream = self.stream + self.instances = [] self.existing_posts = { post.remote_identifier: post - for post in Post.objects.filter(rule=stream.rule) + for post in Post.objects.filter( + rule=stream.rule, rule__type=RuleTypeChoices.feed + ) } return super().__enter__() @@ -73,7 +80,7 @@ class FeedBuilder(Builder): if not field in entry: continue - value = self.truncate_text(model_field, entry[field]) + value = truncate_text(Post, model_field, entry[field]) if field == "published_parsed": data[model_field] = build_publication_date(value, tz) @@ -103,21 +110,6 @@ class FeedBuilder(Builder): strip_comments=True, ) - def truncate_text(self, field_name, value): - field = Post._meta.get_field(field_name) - max_length = field.max_length - cls = type(field) - - if not value or not max_length: - return value - elif not bool(issubclass(cls, CharField) or issubclass(cls, TextField)): - return value - - if len(value) > max_length: - return truncatechars(value, max_length) - - return value - def get_content(self, items): content = "\n ".join([item.get("value") for item in items]) return self.sanitize_fragment(content) @@ -129,21 +121,29 @@ class FeedBuilder(Builder): class FeedStream(Stream): def read(self): - url = self.rule.url - response = fetch(url) + response = fetch(self.rule.url) - return (self.parse(response.content), self) + return self.parse(response), self - def parse(self, payload): + def parse(self, response): try: - return parse(payload) + return parse(response.content) except TypeError as e: - raise StreamParseException("Could not parse feed") from e + message = "Could not parse feed" + raise StreamParseException(response=response, message=message) from e class FeedClient(Client): stream = FeedStream + def __init__(self, rules=[]): + if rules: + self.rules = rules + else: + self.rules = CollectionRule.objects.filter( + enabled=True, type=RuleTypeChoices.feed + ) + def __enter__(self): streams = [self.stream(rule) for rule in self.rules] diff --git a/src/newsreader/news/collection/forms.py b/src/newsreader/news/collection/forms.py index 7e5fc97..1d9b996 100644 --- a/src/newsreader/news/collection/forms.py +++ b/src/newsreader/news/collection/forms.py @@ -1,18 +1,29 @@ from django import forms +from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ import pytz +from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.models import CollectionRule from newsreader.news.core.models import Category +def get_reddit_help_text(): + return mark_safe( + "Only subreddits are supported. For example: " + "https://www.reddit.com/r/aww" + ) + + class CollectionRuleForm(forms.ModelForm): category = forms.ModelChoiceField(required=False, queryset=Category.objects.all()) timezone = forms.ChoiceField( widget=forms.Select(attrs={"size": len(pytz.all_timezones)}), choices=((timezone, timezone) for timezone in pytz.all_timezones), help_text=_("The timezone which the feed uses"), + initial=pytz.utc, ) def __init__(self, *args, **kwargs): @@ -20,8 +31,7 @@ class CollectionRuleForm(forms.ModelForm): super().__init__(*args, **kwargs) - if self.user: - self.fields["category"].queryset = Category.objects.filter(user=self.user) + self.fields["category"].queryset = Category.objects.filter(user=self.user) def save(self, commit=True): instance = super().save(commit=False) @@ -49,6 +59,32 @@ class CollectionRuleBulkForm(forms.Form): self.fields["rules"].queryset = CollectionRule.objects.filter(user=user) +class SubRedditRuleForm(CollectionRuleForm): + url = forms.URLField(max_length=1024, help_text=get_reddit_help_text) + + timezone = None + + def save(self, commit=True): + instance = super().save(commit=False) + + instance.type = RuleTypeChoices.subreddit + instance.timezone = str(pytz.utc) + instance.user = self.user + + if not instance.url.endswith(".json"): + instance.url = f"{instance.url}.json" + + if commit: + instance.save() + self.save_m2m() + + return instance + + class Meta: + model = CollectionRule + fields = ("name", "url", "favicon", "category") + + class OPMLImportForm(forms.Form): file = forms.FileField(allow_empty_file=False) skip_existing = forms.BooleanField(initial=False, required=False) diff --git a/src/newsreader/news/collection/migrations/0008_collectionrule_type.py b/src/newsreader/news/collection/migrations/0008_collectionrule_type.py new file mode 100644 index 0000000..bb8975d --- /dev/null +++ b/src/newsreader/news/collection/migrations/0008_collectionrule_type.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.5 on 2020-06-03 20:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("collection", "0007_collectionrule_enabled")] + + operations = [ + migrations.AddField( + model_name="collectionrule", + name="type", + field=models.CharField( + choices=[("feed", "Feed"), ("subreddit", "Subreddit")], + default="feed", + max_length=20, + ), + ) + ] diff --git a/src/newsreader/news/collection/models.py b/src/newsreader/news/collection/models.py index cc22f8a..35841ba 100644 --- a/src/newsreader/news/collection/models.py +++ b/src/newsreader/news/collection/models.py @@ -1,9 +1,11 @@ from django.db import models +from django.urls import reverse from django.utils.translation import gettext as _ import pytz from newsreader.core.models import TimeStampedModel +from newsreader.news.collection.choices import RuleTypeChoices class CollectionRuleQuerySet(models.QuerySet): @@ -13,6 +15,9 @@ class CollectionRuleQuerySet(models.QuerySet): class CollectionRule(TimeStampedModel): name = models.CharField(max_length=100) + type = models.CharField( + max_length=20, choices=RuleTypeChoices.choices, default=RuleTypeChoices.feed + ) url = models.URLField(max_length=1024) website_url = models.URLField( @@ -23,7 +28,7 @@ class CollectionRule(TimeStampedModel): timezone = models.CharField( choices=((timezone, timezone) for timezone in pytz.all_timezones), max_length=100, - default="UTC", + default=str(pytz.utc), ) category = models.ForeignKey( @@ -38,7 +43,9 @@ class CollectionRule(TimeStampedModel): last_suceeded = models.DateTimeField(blank=True, null=True) succeeded = models.BooleanField(default=False) + error = models.CharField(max_length=1024, blank=True, null=True) + enabled = models.BooleanField( default=True, help_text=_("Wether or not to collect items from this feed") ) @@ -54,3 +61,10 @@ class CollectionRule(TimeStampedModel): def __str__(self): return self.name + + @property + def update_url(self): + if self.type == RuleTypeChoices.subreddit: + return reverse("news:collection:subreddit-update", kwargs={"pk": self.pk}) + + return reverse("news:collection:rule-update", kwargs={"pk": self.pk}) diff --git a/src/newsreader/news/collection/reddit.py b/src/newsreader/news/collection/reddit.py new file mode 100644 index 0000000..2bb7bd9 --- /dev/null +++ b/src/newsreader/news/collection/reddit.py @@ -0,0 +1,307 @@ +import logging + +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timedelta +from html import unescape +from json.decoder import JSONDecodeError +from urllib.parse import urlencode +from uuid import uuid4 + +from django.conf import settings +from django.core.cache import cache +from django.utils import timezone + +import bleach +import pytz +import requests + +from newsreader.news.collection.base import Builder, Client, Collector, Stream +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.constants import ( + WHITELISTED_ATTRIBUTES, + WHITELISTED_TAGS, +) +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamException, + StreamParseException, + StreamTooManyException, +) +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.tasks import RedditTokenTask +from newsreader.news.collection.utils import fetch, post, truncate_text +from newsreader.news.core.models import Post + + +logger = logging.getLogger(__name__) + + +REDDIT_URL = "https://www.reddit.com" +REDDIT_API_URL = "https://oauth.reddit.com" + +RATE_LIMIT = 60 +RATE_LIMIT_DURATION = timedelta(seconds=60) + + +def get_reddit_authorization_url(user): + state = str(uuid4()) + cache.set(f"{user.email}-reddit-auth", state) + + params = { + "client_id": settings.REDDIT_CLIENT_ID, + "redirect_uri": settings.REDDIT_REDIRECT_URL, + "state": state, + "response_type": "code", + "duration": "permanent", + "scope": "identity,mysubreddits,save,read", + } + + authorization_url = f"{REDDIT_URL}/api/v1/authorize" + return f"{authorization_url}?{urlencode(params)}" + + +def get_reddit_access_token(code, user): + client_auth = requests.auth.HTTPBasicAuth( + settings.REDDIT_CLIENT_ID, settings.REDDIT_CLIENT_SECRET + ) + + response = post( + f"{REDDIT_URL}/api/v1/access_token", + data={ + "redirect_uri": settings.REDDIT_REDIRECT_URL, + "grant_type": "authorization_code", + "code": code, + }, + auth=client_auth, + ) + + response_data = response.json() + + user.reddit_access_token = response_data["access_token"] + user.reddit_refresh_token = response_data["refresh_token"] + user.save() + + cache.delete(f"{user.email}-reddit-auth") + + return response_data["access_token"], response_data["refresh_token"] + + +class RedditBuilder(Builder): + def __enter__(self): + _, stream = self.stream + + self.instances = [] + self.existing_posts = { + post.remote_identifier: post + for post in Post.objects.filter( + rule=stream.rule, rule__type=RuleTypeChoices.subreddit + ) + } + + return super().__enter__() + + def create_posts(self, stream): + data, stream = stream + posts = [] + + if not "data" in data or not "children" in data["data"]: + return + + posts = data["data"]["children"] + self.instances = self.build(posts, stream.rule) + + def build(self, posts, rule): + for post in posts: + if not "data" in post: + continue + + remote_identifier = post["data"]["id"] + title = truncate_text(Post, "title", post["data"]["title"]) + author = truncate_text(Post, "author", post["data"]["author"]) + url_fragment = f"{post['data']['permalink']}" + + uncleaned_body = post["data"]["selftext_html"] + unescaped_body = unescape(uncleaned_body) if uncleaned_body else "" + body = ( + bleach.clean( + unescaped_body, + tags=WHITELISTED_TAGS, + attributes=WHITELISTED_ATTRIBUTES, + strip=True, + strip_comments=True, + ) + if unescaped_body + else "" + ) + + try: + parsed_date = datetime.fromtimestamp(post["data"]["created_utc"]) + created_date = pytz.utc.localize(parsed_date) + except (OverflowError, OSError): + logging.warning(f"Failed parsing timestamp from {url_fragment}") + created_date = timezone.now() + + data = { + "remote_identifier": remote_identifier, + "title": title, + "body": body, + "author": author, + "url": f"{REDDIT_URL}{url_fragment}", + "publication_date": created_date, + "rule": rule, + } + + if remote_identifier in self.existing_posts: + existing_post = self.existing_posts[remote_identifier] + + if created_date > existing_post.publication_date: + for key, value in data.items(): + setattr(existing_post, key, value) + + yield existing_post + continue + + yield Post(**data) + + def save(self): + for post in self.instances: + post.save() + + +class RedditScheduler: + max_amount = RATE_LIMIT + max_user_amount = RATE_LIMIT / 4 + + def __init__(self, subreddits=[]): + if not subreddits: + self.subreddits = CollectionRule.objects.filter( + type=RuleTypeChoices.subreddit, + user__reddit_access_token__isnull=False, + user__reddit_refresh_token__isnull=False, + enabled=True, + ).order_by("last_suceeded")[:200] + else: + self.subreddits = subreddits + + def get_scheduled_rules(self): + rule_mapping = {} + current_amount = 0 + + for subreddit in self.subreddits: + user_pk = subreddit.user.pk + + if current_amount == self.max_amount: + break + + if user_pk in rule_mapping: + max_amount_reached = len(rule_mapping[user_pk]) == self.max_user_amount + + if max_amount_reached: + continue + + rule_mapping[user_pk].append(subreddit) + current_amount += 1 + + continue + + rule_mapping[user_pk] = [subreddit] + current_amount += 1 + + return list(rule_mapping.values()) + + +class RedditStream(Stream): + headers = {} + user = None + + def __init__(self, rule): + super().__init__(rule) + + self.user = self.rule.user + self.headers = { + f"Authorization": f"bearer {self.rule.user.reddit_access_token}" + } + + def read(self): + response = fetch(self.rule.url, headers=self.headers) + + return self.parse(response), self + + def parse(self, response): + try: + return response.json() + except JSONDecodeError as e: + raise StreamParseException( + response=response, message=f"Failed parsing json" + ) from e + + +class RedditClient(Client): + stream = RedditStream + + def __init__(self, rules=[]): + self.rules = rules + + def __enter__(self): + streams = [[self.stream(rule) for rule in batch] for batch in self.rules] + rate_limitted = False + + with ThreadPoolExecutor(max_workers=10) as executor: + for batch in streams: + futures = {executor.submit(stream.read): stream for stream in batch} + + if rate_limitted: + break + + for future in as_completed(futures): + stream = futures[future] + + try: + response_data = future.result() + + stream.rule.error = None + stream.rule.succeeded = True + stream.rule.last_suceeded = timezone.now() + + yield response_data + except StreamDeniedException as e: + logger.exception( + f"Access token expired for user {stream.user.pk}" + ) + + stream.rule.user.reddit_access_token = None + stream.rule.user.save() + + self.set_rule_error(stream.rule, e) + + RedditTokenTask.delay(stream.rule.user.pk) + + break + except StreamTooManyException as e: + logger.exception("Ratelimit hit, aborting batched subreddits") + + self.set_rule_error(stream.rule, e) + + rate_limitted = True + break + except StreamException as e: + logger.exception( + "Stream failed reading content from " f"{stream.rule.url}" + ) + + self.set_rule_error(stream.rule, e) + + continue + finally: + stream.rule.save() + + def set_rule_error(self, rule, exception): + length = rule._meta.get_field("error").max_length + + rule.error = exception.message[-length:] + rule.succeeded = False + + +class RedditCollector(Collector): + builder = RedditBuilder + client = RedditClient diff --git a/src/newsreader/news/collection/response_handler.py b/src/newsreader/news/collection/response_handler.py index 3a16376..2cd785d 100644 --- a/src/newsreader/news/collection/response_handler.py +++ b/src/newsreader/news/collection/response_handler.py @@ -1,12 +1,13 @@ from requests.exceptions import ConnectionError as RequestConnectionError from newsreader.news.collection.exceptions import ( - StreamConnectionError, + StreamConnectionException, StreamDeniedException, StreamException, StreamForbiddenException, StreamNotFoundException, StreamTimeOutException, + StreamTooManyException, ) @@ -16,9 +17,10 @@ class ResponseHandler: 401: StreamDeniedException, 403: StreamForbiddenException, 408: StreamTimeOutException, + 429: StreamTooManyException, } - exception_mapping = {RequestConnectionError: StreamConnectionError} + exception_mapping = {RequestConnectionError: StreamConnectionException} def __enter__(self): return self @@ -27,16 +29,20 @@ class ResponseHandler: status_code = response.status_code if status_code in self.status_code_mapping: - raise self.status_code_mapping[status_code] + exception = self.status_code_mapping[status_code] + raise exception(response) + + def map_exception(self, exception): + if isinstance(exception, StreamException): + raise exception - def handle_exception(self, exception): try: stream_exception = self.exception_mapping[type(exception)] except KeyError: stream_exception = StreamException message = getattr(exception, "message", str(exception)) - raise stream_exception(message=message) from exception + raise stream_exception(exception.response, message=message) from exception def __exit__(self, *args, **kwargs): pass diff --git a/src/newsreader/news/collection/tasks.py b/src/newsreader/news/collection/tasks.py index dab94d4..d368a5c 100644 --- a/src/newsreader/news/collection/tasks.py +++ b/src/newsreader/news/collection/tasks.py @@ -1,11 +1,15 @@ +from django.conf import settings from django.core.exceptions import ObjectDoesNotExist +import requests + from celery.exceptions import Reject from celery.utils.log import get_task_logger from newsreader.accounts.models import User from newsreader.celery import app from newsreader.news.collection.feed import FeedCollector +from newsreader.news.collection.utils import post from newsreader.utils.celery import MemCacheLock @@ -13,7 +17,7 @@ logger = get_task_logger(__name__) class FeedTask(app.Task): - name = "newsreader.news.collection.tasks.FeedTask" + name = "FeedTask" ignore_result = True def run(self, user_pk): @@ -41,4 +45,74 @@ class FeedTask(app.Task): raise Reject(reason="Task already running", requeue=False) +class RedditTask(app.Task): + name = "RedditTask" + ignore_result = True + + def run(self): + from newsreader.news.collection.reddit import RedditCollector, RedditScheduler + + with MemCacheLock("reddit-task", self.app.oid) as acquired: + if acquired: + logger.info(f"Running reddit task") + + scheduler = RedditScheduler() + subreddits = scheduler.get_scheduled_rules() + + collector = RedditCollector() + collector.collect(rules=subreddits) + else: + logger.warning(f"Cancelling task due to existing lock") + + raise Reject(reason="Task already running", requeue=False) + + +class RedditTokenTask(app.Task): + name = "RedditTokenTask" + ignore_result = True + + def run(self, user_pk): + from newsreader.news.collection.reddit import REDDIT_URL + + try: + user = User.objects.get(pk=user_pk) + except ObjectDoesNotExist: + message = f"User {user_pk} does not exist" + logger.exception(message) + + raise Reject(reason=message, requeue=False) + + if not user.reddit_refresh_token: + raise Reject(reason=f"User {user_pk} has no refresh token", requeue=False) + + client_auth = requests.auth.HTTPBasicAuth( + settings.REDDIT_CLIENT_ID, settings.REDDIT_CLIENT_SECRET + ) + + try: + response = post( + f"{REDDIT_URL}/api/v1/access_token", + data={ + "grant_type": "refresh_token", + "refresh_token": user.reddit_refresh_token, + }, + auth=client_auth, + ) + except StreamException: + logger.exception( + f"Failed refreshing reddit access token for user {user_pk}" + ) + + user.reddit_refresh_token = None + user.save() + return + + response_data = response.json() + + user.reddit_access_token = response_data["access_token"] + user.save() + + FeedTask = app.register_task(FeedTask()) +RedditTask = app.register_task(RedditTask()) +RedditTokenTask = app.register_task(RedditTokenTask()) diff --git a/src/newsreader/news/collection/templates/news/collection/views/rules.html b/src/newsreader/news/collection/templates/news/collection/views/rules.html index 1da7c4d..b8ab514 100644 --- a/src/newsreader/news/collection/templates/news/collection/views/rules.html +++ b/src/newsreader/news/collection/templates/news/collection/views/rules.html @@ -15,6 +15,7 @@ @@ -48,7 +49,7 @@ {{ rule.succeeded }} {{ rule.enabled }} - + {% endfor %} diff --git a/src/newsreader/news/collection/templates/news/collection/views/subreddit-create.html b/src/newsreader/news/collection/templates/news/collection/views/subreddit-create.html new file mode 100644 index 0000000..6250e4e --- /dev/null +++ b/src/newsreader/news/collection/templates/news/collection/views/subreddit-create.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
      + {% url "news:collection:rules" as cancel_url %} + {% include "components/form/form.html" with form=form title="Add a subreddit" cancel_url=cancel_url confirm_text="Add subrredit" %} +
      +{% endblock %} diff --git a/src/newsreader/news/collection/templates/news/collection/views/subreddit-update.html b/src/newsreader/news/collection/templates/news/collection/views/subreddit-update.html new file mode 100644 index 0000000..9ea7d05 --- /dev/null +++ b/src/newsreader/news/collection/templates/news/collection/views/subreddit-update.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
      + {% url "news:collection:rules" as cancel_url %} + {% include "components/form/form.html" with form=form title="Update subreddit" cancel_url=cancel_url confirm_text="Save subreddit" %} +
      +{% endblock %} diff --git a/src/newsreader/news/collection/tests/factories.py b/src/newsreader/news/collection/tests/factories.py index 678e0f4..fdf786f 100644 --- a/src/newsreader/news/collection/tests/factories.py +++ b/src/newsreader/news/collection/tests/factories.py @@ -1,7 +1,9 @@ import factory from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.reddit import REDDIT_URL class CollectionRuleFactory(factory.django.DjangoModelFactory): @@ -17,3 +19,12 @@ class CollectionRuleFactory(factory.django.DjangoModelFactory): class Meta: model = CollectionRule + + +class FeedFactory(CollectionRuleFactory): + type = RuleTypeChoices.feed + + +class SubredditFactory(CollectionRuleFactory): + type = RuleTypeChoices.subreddit + website_url = REDDIT_URL diff --git a/src/newsreader/news/collection/tests/feed/builder/tests.py b/src/newsreader/news/collection/tests/feed/builder/tests.py index cfafa4f..7069f96 100644 --- a/src/newsreader/news/collection/tests/feed/builder/tests.py +++ b/src/newsreader/news/collection/tests/feed/builder/tests.py @@ -9,7 +9,7 @@ import pytz from freezegun import freeze_time from newsreader.news.collection.feed import FeedBuilder -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory from newsreader.news.core.models import Post from newsreader.news.core.tests.factories import PostFactory @@ -23,7 +23,7 @@ class FeedBuilderTestCase(TestCase): def test_basic_entry(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((simple_mock, mock_stream)) as builder: @@ -54,7 +54,7 @@ class FeedBuilderTestCase(TestCase): def test_multiple_entries(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((multiple_mock, mock_stream)) as builder: @@ -115,7 +115,7 @@ class FeedBuilderTestCase(TestCase): def test_entries_without_remote_identifier(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_without_identifier, mock_stream)) as builder: @@ -154,7 +154,7 @@ class FeedBuilderTestCase(TestCase): def test_entry_without_publication_date(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_without_publish_date, mock_stream)) as builder: @@ -186,7 +186,7 @@ class FeedBuilderTestCase(TestCase): def test_entry_without_url(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_without_url, mock_stream)) as builder: @@ -212,7 +212,7 @@ class FeedBuilderTestCase(TestCase): def test_entry_without_body(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_without_body, mock_stream)) as builder: @@ -246,7 +246,7 @@ class FeedBuilderTestCase(TestCase): def test_entry_without_author(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_without_author, mock_stream)) as builder: @@ -274,7 +274,7 @@ class FeedBuilderTestCase(TestCase): def test_empty_entries(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_without_entries, mock_stream)) as builder: @@ -284,7 +284,7 @@ class FeedBuilderTestCase(TestCase): def test_update_entries(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) existing_first_post = PostFactory.create( @@ -314,7 +314,7 @@ class FeedBuilderTestCase(TestCase): def test_html_sanitizing(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_with_html, mock_stream)) as builder: @@ -336,7 +336,7 @@ class FeedBuilderTestCase(TestCase): def test_long_author_text_is_truncated(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_with_long_author, mock_stream)) as builder: @@ -350,7 +350,7 @@ class FeedBuilderTestCase(TestCase): def test_long_title_text_is_truncated(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_with_long_title, mock_stream)) as builder: @@ -364,7 +364,7 @@ class FeedBuilderTestCase(TestCase): def test_content_detail_is_prioritized_if_longer(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_with_longer_content_detail, mock_stream)) as builder: @@ -381,7 +381,7 @@ class FeedBuilderTestCase(TestCase): def test_content_detail_is_not_prioritized_if_shorter(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_with_shorter_content_detail, mock_stream)) as builder: @@ -397,7 +397,7 @@ class FeedBuilderTestCase(TestCase): def test_content_detail_is_concatinated(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_with_multiple_content_detail, mock_stream)) as builder: diff --git a/src/newsreader/news/collection/tests/feed/client/tests.py b/src/newsreader/news/collection/tests/feed/client/tests.py index dd3c1e4..59b5f65 100644 --- a/src/newsreader/news/collection/tests/feed/client/tests.py +++ b/src/newsreader/news/collection/tests/feed/client/tests.py @@ -11,7 +11,7 @@ from newsreader.news.collection.exceptions import ( StreamTimeOutException, ) from newsreader.news.collection.feed import FeedClient -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory from .mocks import simple_mock @@ -27,8 +27,9 @@ class FeedClientTestCase(TestCase): patch.stopall() def test_client_retrieves_single_rules(self): - rule = CollectionRuleFactory.create() + rule = FeedFactory.create() mock_stream = MagicMock(rule=rule) + self.mocked_read.return_value = (simple_mock, mock_stream) with FeedClient([rule]) as client: @@ -39,9 +40,10 @@ class FeedClientTestCase(TestCase): self.mocked_read.assert_called_once_with() def test_client_catches_stream_exception(self): - rule = CollectionRuleFactory.create() + rule = FeedFactory.create() mock_stream = MagicMock(rule=rule) - self.mocked_read.side_effect = StreamException("Stream exception") + + self.mocked_read.side_effect = StreamException(message="Stream exception") with FeedClient([rule]) as client: for data, stream in client: @@ -52,9 +54,12 @@ class FeedClientTestCase(TestCase): self.mocked_read.assert_called_once_with() def test_client_catches_stream_not_found_exception(self): - rule = CollectionRuleFactory.create() + rule = FeedFactory.create() mock_stream = MagicMock(rule=rule) - self.mocked_read.side_effect = StreamNotFoundException("Stream not found") + + self.mocked_read.side_effect = StreamNotFoundException( + message="Stream not found" + ) with FeedClient([rule]) as client: for data, stream in client: @@ -65,9 +70,10 @@ class FeedClientTestCase(TestCase): self.mocked_read.assert_called_once_with() def test_client_catches_stream_denied_exception(self): - rule = CollectionRuleFactory.create() + rule = FeedFactory.create() mock_stream = MagicMock(rule=rule) - self.mocked_read.side_effect = StreamDeniedException("Stream denied") + + self.mocked_read.side_effect = StreamDeniedException(message="Stream denied") with FeedClient([rule]) as client: for data, stream in client: @@ -78,9 +84,12 @@ class FeedClientTestCase(TestCase): self.mocked_read.assert_called_once_with() def test_client_catches_stream_timed_out(self): - rule = CollectionRuleFactory.create() + rule = FeedFactory.create() mock_stream = MagicMock(rule=rule) - self.mocked_read.side_effect = StreamTimeOutException("Stream timed out") + + self.mocked_read.side_effect = StreamTimeOutException( + message="Stream timed out" + ) with FeedClient([rule]) as client: for data, stream in client: @@ -91,22 +100,12 @@ class FeedClientTestCase(TestCase): self.mocked_read.assert_called_once_with() def test_client_catches_stream_parse_exception(self): - rule = CollectionRuleFactory.create() + rule = FeedFactory.create() mock_stream = MagicMock(rule=rule) - self.mocked_read.side_effect = StreamParseException("Stream has wrong contents") - with FeedClient([rule]) as client: - for data, stream in client: - self.assertEquals(data, {"entries": []}) - self.assertEquals(stream.rule.error, "Stream has wrong contents") - self.assertEquals(stream.rule.succeeded, False) - - self.mocked_read.assert_called_once_with() - - def test_client_catches_stream_parse_exception(self): - rule = CollectionRuleFactory.create() - mock_stream = MagicMock(rule=rule) - self.mocked_read.side_effect = StreamParseException("Stream has wrong contents") + self.mocked_read.side_effect = StreamParseException( + message="Stream has wrong contents" + ) with FeedClient([rule]) as client: for data, stream in client: @@ -117,9 +116,10 @@ class FeedClientTestCase(TestCase): self.mocked_read.assert_called_once_with() def test_client_catches_long_exception_text(self): - rule = CollectionRuleFactory.create() + rule = FeedFactory.create() mock_stream = MagicMock(rule=rule) - self.mocked_read.side_effect = StreamParseException(words(1000)) + + self.mocked_read.side_effect = StreamParseException(message=words(1000)) with FeedClient([rule]) as client: for data, stream in client: diff --git a/src/newsreader/news/collection/tests/feed/collector/tests.py b/src/newsreader/news/collection/tests/feed/collector/tests.py index 0506783..b0fc7cf 100644 --- a/src/newsreader/news/collection/tests/feed/collector/tests.py +++ b/src/newsreader/news/collection/tests/feed/collector/tests.py @@ -18,7 +18,7 @@ from newsreader.news.collection.exceptions import ( StreamTimeOutException, ) from newsreader.news.collection.feed import FeedCollector -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory from newsreader.news.collection.utils import build_publication_date from newsreader.news.core.models import Post from newsreader.news.core.tests.factories import PostFactory @@ -42,7 +42,7 @@ class FeedCollectorTestCase(TestCase): @freeze_time("2019-10-30 12:30:00") def test_simple_batch(self): self.mocked_parse.return_value = multiple_mock - rule = CollectionRuleFactory() + rule = FeedFactory() collector = FeedCollector() collector.collect() @@ -58,7 +58,7 @@ class FeedCollectorTestCase(TestCase): def test_emtpy_batch(self): self.mocked_fetch.return_value = MagicMock() self.mocked_parse.return_value = empty_mock - rule = CollectionRuleFactory() + rule = FeedFactory() collector = FeedCollector() collector.collect() @@ -72,7 +72,7 @@ class FeedCollectorTestCase(TestCase): def test_not_found(self): self.mocked_fetch.side_effect = StreamNotFoundException - rule = CollectionRuleFactory() + rule = FeedFactory() collector = FeedCollector() collector.collect() @@ -88,7 +88,7 @@ class FeedCollectorTestCase(TestCase): last_suceeded = timezone.make_aware( datetime.combine(date=date(2019, 10, 30), time=time(12, 30)) ) - rule = CollectionRuleFactory(last_suceeded=last_suceeded) + rule = FeedFactory(last_suceeded=last_suceeded) collector = FeedCollector() collector.collect() @@ -105,7 +105,7 @@ class FeedCollectorTestCase(TestCase): last_suceeded = timezone.make_aware( datetime.combine(date=date(2019, 10, 30), time=time(12, 30)) ) - rule = CollectionRuleFactory(last_suceeded=last_suceeded) + rule = FeedFactory(last_suceeded=last_suceeded) collector = FeedCollector() collector.collect() @@ -122,7 +122,7 @@ class FeedCollectorTestCase(TestCase): last_suceeded = timezone.make_aware( datetime.combine(date=date(2019, 10, 30), time=time(12, 30)) ) - rule = CollectionRuleFactory(last_suceeded=last_suceeded) + rule = FeedFactory(last_suceeded=last_suceeded) collector = FeedCollector() collector.collect() @@ -137,7 +137,7 @@ class FeedCollectorTestCase(TestCase): @freeze_time("2019-10-30 12:30:00") def test_duplicates(self): self.mocked_parse.return_value = duplicate_mock - rule = CollectionRuleFactory() + rule = FeedFactory() aware_datetime = build_publication_date( struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), pytz.utc @@ -192,7 +192,7 @@ class FeedCollectorTestCase(TestCase): @freeze_time("2019-02-22 12:30:00") def test_items_with_identifiers_get_updated(self): self.mocked_parse.return_value = multiple_update_mock - rule = CollectionRuleFactory() + rule = FeedFactory() first_post = PostFactory( remote_identifier="https://www.bbc.co.uk/news/world-us-canada-48338168", @@ -248,10 +248,7 @@ class FeedCollectorTestCase(TestCase): @freeze_time("2019-02-22 12:30:00") def test_disabled_rules(self): - rules = ( - CollectionRuleFactory(enabled=False), - CollectionRuleFactory(enabled=True), - ) + rules = (FeedFactory(enabled=False), FeedFactory(enabled=True)) self.mocked_parse.return_value = multiple_mock diff --git a/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py index 109491b..18a6c6c 100644 --- a/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py +++ b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py @@ -6,7 +6,7 @@ from django.utils import timezone from freezegun import freeze_time from newsreader.news.collection.feed import FeedDuplicateHandler -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory from newsreader.news.core.models import Post from newsreader.news.core.tests.factories import PostFactory @@ -17,7 +17,7 @@ class FeedDuplicateHandlerTestCase(TestCase): self.maxDiff = None def test_duplicate_entries_with_remote_identifiers(self): - rule = CollectionRuleFactory() + rule = FeedFactory() existing_post = PostFactory.create( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", rule=rule @@ -52,7 +52,7 @@ class FeedDuplicateHandlerTestCase(TestCase): self.assertEquals(post.read, False) def test_duplicate_entries_with_different_remote_identifiers(self): - rule = CollectionRuleFactory() + rule = FeedFactory() existing_post = PostFactory( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", @@ -98,7 +98,7 @@ class FeedDuplicateHandlerTestCase(TestCase): self.assertEquals(post.read, False) def test_duplicate_entries_in_recent_database(self): - rule = CollectionRuleFactory() + rule = FeedFactory() existing_post = PostFactory( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", @@ -145,7 +145,7 @@ class FeedDuplicateHandlerTestCase(TestCase): self.assertEquals(post.read, False) def test_multiple_existing_entries_with_identifier(self): - rule = CollectionRuleFactory() + rule = FeedFactory() PostFactory.create_batch( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", rule=rule, size=5 @@ -187,7 +187,7 @@ class FeedDuplicateHandlerTestCase(TestCase): self.assertEquals(post.read, False) def test_duplicate_entries_outside_time_slot(self): - rule = CollectionRuleFactory() + rule = FeedFactory() existing_post = PostFactory( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", @@ -234,7 +234,7 @@ class FeedDuplicateHandlerTestCase(TestCase): self.assertEquals(post.read, False) def test_duplicate_entries_in_collected_entries(self): - rule = CollectionRuleFactory() + rule = FeedFactory() post_1 = PostFactory.build( title="title got updated", body="body", diff --git a/src/newsreader/news/collection/tests/feed/stream/mocks.py b/src/newsreader/news/collection/tests/feed/stream/mocks.py index 7dfeba6..4218355 100644 --- a/src/newsreader/news/collection/tests/feed/stream/mocks.py +++ b/src/newsreader/news/collection/tests/feed/stream/mocks.py @@ -1,59 +1,174 @@ from time import struct_time -simple_mock = { - "bozo": 1, +simple_mock = bytes( + """ + + + + <![CDATA[BBC News - Home]]> + + https://www.bbc.co.uk/news/ + + https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif + BBC News - Home + https://www.bbc.co.uk/news/ + + RSS for Node + Sun, 12 Jul 2020 17:21:20 GMT + + + 15 + + <![CDATA[Coronavirus: I trust people's sense on face masks - Gove]]> + + https://www.bbc.co.uk/news/uk-53381000 + https://www.bbc.co.uk/news/uk-53381000 + Sun, 12 Jul 2020 16:15:03 GMT + + + <![CDATA[Farm outbreak leads 200 to self isolate ]]> + + https://www.bbc.co.uk/news/uk-england-hereford-worcester-53381802 + https://www.bbc.co.uk/news/uk-england-hereford-worcester-53381802 + Sun, 12 Jul 2020 17:19:31 GMT + + + <![CDATA[English Channel search operation after migrant crossings]]> + + https://www.bbc.co.uk/news/uk-53382563 + https://www.bbc.co.uk/news/uk-53382563 + Sun, 12 Jul 2020 15:47:17 GMT + + + """, + "utf-8", +) + + +simple_mock_parsed = { + "bozo": 0, "encoding": "utf-8", "entries": [ { "guidislink": False, - "href": "", - "id": "https://www.bbc.co.uk/news/world-us-canada-48338168", - "link": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "id": "https://www.bbc.co.uk/news/uk-53381000", + "link": "https://www.bbc.co.uk/news/uk-53381000", "links": [ { - "href": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "href": "https://www.bbc.co.uk/news/uk-53381000", "rel": "alternate", "type": "text/html", } ], - "media_thumbnail": [ - { - "height": "1152", - "url": "http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg", - "width": "2048", - } - ], - "published": "Mon, 20 May 2019 16:07:37 GMT", - "published_parsed": struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), - "summary": "Foreign Minister Mohammad Javad Zarif says the US " - "president should try showing Iranians some respect.", + "published": "Sun, 12 Jul 2020 16:15:03 GMT", + "published_parsed": struct_time((2020, 7, 12, 16, 15, 3, 6, 194, 0)), + "summary": "Minister Michael Gove says he does not think face " + "coverings should be mandatory in shops in England.", "summary_detail": { - "base": "http://feeds.bbci.co.uk/news/rss.xml", + "base": "", "language": None, "type": "text/html", - "value": "Foreign Minister Mohammad Javad " - "Zarif says the US president should " - "try showing Iranians some " - "respect.", + "value": "Minister Michael Gove says he does " + "not think face coverings should be " + "mandatory in shops in England.", }, - "title": "Trump's 'genocidal taunts' will not end Iran - Zarif", + "title": "Coronavirus: I trust people's sense on face masks - " "Gove", "title_detail": { - "base": "http://feeds.bbci.co.uk/news/rss.xml", + "base": "", "language": None, "type": "text/plain", - "value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif", + "value": "Coronavirus: I trust people's sense " "on face masks - Gove", }, - } + }, + { + "guidislink": False, + "id": "https://www.bbc.co.uk/news/uk-england-hereford-worcester-53381802", + "link": "https://www.bbc.co.uk/news/uk-england-hereford-worcester-53381802", + "links": [ + { + "href": "https://www.bbc.co.uk/news/uk-england-hereford-worcester-53381802", + "rel": "alternate", + "type": "text/html", + } + ], + "published": "Sun, 12 Jul 2020 17:19:31 GMT", + "published_parsed": struct_time((2020, 7, 12, 17, 19, 31, 6, 194, 0)), + "summary": "Up to 200 vegetable pickers and packers will remain " + "on the farm in Herefordshire while isolating.", + "summary_detail": { + "base": "", + "language": None, + "type": "text/html", + "value": "Up to 200 vegetable pickers and " + "packers will remain on the farm in " + "Herefordshire while isolating.", + }, + "title": "Farm outbreak leads 200 to self isolate", + "title_detail": { + "base": "", + "language": None, + "type": "text/plain", + "value": "Farm outbreak leads 200 to self " "isolate", + }, + }, + { + "guidislink": False, + "id": "https://www.bbc.co.uk/news/uk-53382563", + "link": "https://www.bbc.co.uk/news/uk-53382563", + "links": [ + { + "href": "https://www.bbc.co.uk/news/uk-53382563", + "rel": "alternate", + "type": "text/html", + } + ], + "published": "Sun, 12 Jul 2020 15:47:17 GMT", + "published_parsed": struct_time((2020, 7, 12, 15, 47, 17, 6, 194, 0)), + "summary": "Several boats are spotted as the home secretary " + "visits France for talks on tackling people " + "smuggling.", + "summary_detail": { + "base": "", + "language": None, + "type": "text/html", + "value": "Several boats are spotted as the " + "home secretary visits France for " + "talks on tackling people " + "smuggling.", + }, + "title": "English Channel search operation after migrant " "crossings", + "title_detail": { + "base": "", + "language": None, + "type": "text/plain", + "value": "English Channel search operation " "after migrant crossings", + }, + }, ], "feed": { + "generator": "RSS for Node", + "generator_detail": {"name": "RSS for Node"}, "image": { "href": "https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif", "link": "https://www.bbc.co.uk/news/", + "links": [ + { + "href": "https://www.bbc.co.uk/news/", + "rel": "alternate", + "type": "text/html", + } + ], "title": "BBC News - Home", - "language": "en-gb", - "link": "https://www.bbc.co.uk/news/", + "title_detail": { + "base": "", + "language": None, + "type": "text/plain", + "value": "BBC News - Home", + }, }, + "language": "en-gb", + "link": "https://www.bbc.co.uk/news/", "links": [ { "href": "https://www.bbc.co.uk/news/", @@ -61,9 +176,41 @@ simple_mock = { "type": "text/html", } ], + "rights": "Copyright: (C) British Broadcasting Corporation, see " + "http://news.bbc.co.uk/2/hi/help/rss/4498287.stm for terms " + "and conditions of reuse.", + "rights_detail": { + "base": "", + "language": None, + "type": "text/plain", + "value": "Copyright: (C) British Broadcasting " + "Corporation, see " + "http://news.bbc.co.uk/2/hi/help/rss/4498287.stm " + "for terms and conditions of reuse.", + }, + "subtitle": "BBC News - Home", + "subtitle_detail": { + "base": "", + "language": None, + "type": "text/html", + "value": "BBC News - Home", + }, "title": "BBC News - Home", + "title_detail": { + "base": "", + "language": None, + "type": "text/plain", + "value": "BBC News - Home", + }, + "ttl": "15", + "updated": "Sun, 12 Jul 2020 17:21:20 GMT", + "updated_parsed": struct_time((2020, 7, 12, 17, 21, 20, 6, 194, 0)), + }, + "namespaces": { + "": "http://www.w3.org/2005/Atom", + "content": "http://purl.org/rss/1.0/modules/content/", + "dc": "http://purl.org/dc/elements/1.1/", + "media": "http://search.yahoo.com/mrss/", }, - "href": "http://feeds.bbci.co.uk/news/rss.xml", - "status": 200, "version": "rss20", } diff --git a/src/newsreader/news/collection/tests/feed/stream/tests.py b/src/newsreader/news/collection/tests/feed/stream/tests.py index 7c0f203..82a09a3 100644 --- a/src/newsreader/news/collection/tests/feed/stream/tests.py +++ b/src/newsreader/news/collection/tests/feed/stream/tests.py @@ -11,9 +11,9 @@ from newsreader.news.collection.exceptions import ( StreamTimeOutException, ) from newsreader.news.collection.feed import FeedStream -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory -from .mocks import simple_mock +from .mocks import simple_mock, simple_mock_parsed class FeedStreamTestCase(TestCase): @@ -29,19 +29,19 @@ class FeedStreamTestCase(TestCase): def test_simple_stream(self): self.mocked_fetch.return_value = MagicMock(content=simple_mock) - rule = CollectionRuleFactory() + rule = FeedFactory() stream = FeedStream(rule) data, stream = stream.read() self.mocked_fetch.assert_called_once_with(rule.url) - self.assertEquals(data["entries"], data["entries"]) - self.assertEquals(stream, stream) + self.assertEquals(data, simple_mock_parsed) + self.assertEquals(stream.rule, rule) def test_stream_raises_exception(self): self.mocked_fetch.side_effect = StreamException - rule = CollectionRuleFactory() + rule = FeedFactory() stream = FeedStream(rule) with self.assertRaises(StreamException): @@ -52,7 +52,7 @@ class FeedStreamTestCase(TestCase): def test_stream_raises_denied_exception(self): self.mocked_fetch.side_effect = StreamDeniedException - rule = CollectionRuleFactory() + rule = FeedFactory() stream = FeedStream(rule) with self.assertRaises(StreamDeniedException): @@ -63,7 +63,7 @@ class FeedStreamTestCase(TestCase): def test_stream_raises_not_found_exception(self): self.mocked_fetch.side_effect = StreamNotFoundException - rule = CollectionRuleFactory() + rule = FeedFactory() stream = FeedStream(rule) with self.assertRaises(StreamNotFoundException): @@ -74,7 +74,7 @@ class FeedStreamTestCase(TestCase): def test_stream_raises_time_out_exception(self): self.mocked_fetch.side_effect = StreamTimeOutException - rule = CollectionRuleFactory() + rule = FeedFactory() stream = FeedStream(rule) with self.assertRaises(StreamTimeOutException): @@ -85,7 +85,7 @@ class FeedStreamTestCase(TestCase): def test_stream_raises_forbidden_exception(self): self.mocked_fetch.side_effect = StreamForbiddenException - rule = CollectionRuleFactory() + rule = FeedFactory() stream = FeedStream(rule) with self.assertRaises(StreamForbiddenException): @@ -98,7 +98,7 @@ class FeedStreamTestCase(TestCase): self.mocked_fetch.return_value = MagicMock() mocked_parse.side_effect = TypeError - rule = CollectionRuleFactory() + rule = FeedFactory() stream = FeedStream(rule) with self.assertRaises(StreamParseException): diff --git a/src/newsreader/news/collection/tests/reddit/__init__.py b/src/newsreader/news/collection/tests/reddit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/reddit/builder/__init__.py b/src/newsreader/news/collection/tests/reddit/builder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/reddit/builder/mocks.py b/src/newsreader/news/collection/tests/reddit/builder/mocks.py new file mode 100644 index 0000000..53ce372 --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/builder/mocks.py @@ -0,0 +1,1378 @@ +simple_mock = { + "kind": "Listing", + "data": { + "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", + "dist": 27, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "Welcome to r/linux rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.\n\nLet us know what's annoying you, whats making you happy, or something that you want to get out to r/linux but didn't make the cut into a full post of it's own.\n\nFor those looking for certifications please use this megathread to ask about how to get certified whether it's for the business world or for your own satisfaction. Be sure to check out r/linuxadmin for more discussion in the SysAdmin world!\n\n_Please keep questions in r/linuxquestions, r/linux4noobs, or the Wednesday automod thread._", + "author_fullname": "t2_6l4z3", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hm0qct", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.7, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 8, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 8, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594037482.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": "<!-- SC_OFF --><div class='md'><p>Welcome to <a href='/r/linux'>r/linux</a> rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.</p>\n\n<p>Let us know what&#39;s annoying you, whats making you happy, or something that you want to get out to <a href='/r/linux'>r/linux</a> but didn&#39;t make the cut into a full post of it&#39;s own.</p>\n\n<p>For those looking for certifications please use this megathread to ask about how to get certified whether it&#39;s for the business world or for your own satisfaction. Be sure to check out <a href='/r/linuxadmin'>r/linuxadmin</a> for more discussion in the SysAdmin world!</p>\n\n<p><em>Please keep questions in <a href='/r/linuxquestions'>r/linuxquestions</a>, <a href='/r/linux4noobs'>r/linux4noobs</a>, or the Wednesday automod thread.</em></p>\n</div><!-- SC_ON -->", + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": "moderator", + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hm0qct", + "is_robot_indexable": True, + "report_reasons": None, + "author": "AutoModerator", + "discussion_type": None, + "num_comments": 9, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", + "subreddit_subscribers": 544037, + "created_utc": 1594008682.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "Welcome to r/linux! If you're new to Linux or trying to get started this thread is for you. Get help here or as always, check out r/linuxquestions or r/linux4noobs\n\nThis megathread is for all your question needs. As we don't allow questions on r/linux outside of this megathread, please consider using r/linuxquestions or r/linux4noobs for the best solution to your problem.\n\nAsk your hardware requests here too or try r/linuxhardware!", + "author_fullname": "t2_6l4z3", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Weekly Questions and Hardware Thread - July 08, 2020", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hna75r", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.6, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 2, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 2, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594210138.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>Welcome to <a href="/r/linux">r/linux</a>! If you&#39;re new to Linux or trying to get started this thread is for you. Get help here or as always, check out <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a></p>\n\n<p>This megathread is for all your question needs. As we don&#39;t allow questions on <a href="/r/linux">r/linux</a> outside of this megathread, please consider using <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a> for the best solution to your problem.</p>\n\n<p>Ask your hardware requests here too or try <a href="/r/linuxhardware">r/linuxhardware</a>!</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": "new", + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": "moderator", + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hna75r", + "is_robot_indexable": True, + "report_reasons": None, + "author": "AutoModerator", + "discussion_type": None, + "num_comments": 2, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", + "subreddit_subscribers": 544037, + "created_utc": 1594181338.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_gr7k5", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Here's a feature Linux could borrow from BSD: in-kernel debugger with built-in hangman game", + "link_flair_richtext": [{"e": "text", "t": "Fluff"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hngs71", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.9, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 158, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Fluff", + "can_mod_post": False, + "score": 158, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594242629.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/wmc8tp2ium951.jpg", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "af8918be-6777-11e7-8273-0e925d908786", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#9a2bff", + "id": "hngs71", + "is_robot_indexable": True, + "report_reasons": None, + "author": "the_humeister", + "discussion_type": None, + "num_comments": 21, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hngs71/heres_a_feature_linux_could_borrow_from_bsd/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/wmc8tp2ium951.jpg", + "subreddit_subscribers": 544037, + "created_utc": 1594213829.0, + "num_crossposts": 1, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_k9f35", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "KeePassXC 2.6.0 released", + "link_flair_richtext": [{"e": "text", "t": "Software Release"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hngsj8", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.97, + "author_flair_background_color": "transparent", + "subreddit_type": "public", + "ups": 151, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": "fa602e36-cdf6-11e8-93c9-0e41ac35f4cc", + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Software Release", + "can_mod_post": False, + "score": 151, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [ + { + "a": ":ubuntu:", + "e": "emoji", + "u": "https://emoji.redditmedia.com/uwmddx7qqpr11_t5_2qh1a/ubuntu", + } + ], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594242666.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "keepassxc.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://keepassxc.org/blog/2020-07-07-2.6.0-released/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "904ea3e4-6748-11e7-b925-0ef3dfbb807a", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": ":ubuntu:", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#349e48", + "id": "hngsj8", + "is_robot_indexable": True, + "report_reasons": None, + "author": "nixcraft", + "discussion_type": None, + "num_comments": 46, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/linux/comments/hngsj8/keepassxc_260_released/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://keepassxc.org/blog/2020-07-07-2.6.0-released/", + "subreddit_subscribers": 544037, + "created_utc": 1594213866.0, + "num_crossposts": 1, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [{"e": "text", "t": "Popular Application"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd7cy", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.95, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 226, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Popular Application", + "can_mod_post": False, + "score": 226, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [ + { + "approved_at_utc": None, + "subreddit": "libreoffice", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/libreoffice", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd6yo", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.96, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 29, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "News", + "can_mod_post": False, + "score": 29, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594224961.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "dc82ac98-bafb-11e4-9f88-22000b310327", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2s4nt", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnd6yo", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZero", + "discussion_type": None, + "num_comments": 38, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/libreoffice/comments/hnd6yo/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 4669, + "created_utc": 1594196161.0, + "num_crossposts": 2, + "media": None, + "is_video": False, + } + ], + "created": 1594225018.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "7127ec98-5859-11e8-9488-0e8717893ec8", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#0aa18f", + "id": "hnd7cy", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZero", + "discussion_type": None, + "num_comments": 120, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_hnd6yo", + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnd7cy/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 544037, + "created_utc": 1594196218.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + ], + "after": "t3_hmytic", + "before": None, + }, +} + +empty_mock = { + "kind": "Listing", + "data": { + "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", + "dist": 27, + "children": [], + "after": "t3_hmytic", + "before": None, + }, +} + +unknown_mock = { + "kind": "Comment", + "data": { + "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", + "dist": 27, + "after": "t3_hmytic", + "before": None, + }, +} + +unsanitized_mock = { + "kind": "Listing", + "data": { + "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", + "dist": 27, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [{"e": "text", "t": "Popular Application"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd7cy", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.95, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 226, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Popular Application", + "can_mod_post": False, + "score": 226, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [ + { + "approved_at_utc": None, + "subreddit": "libreoffice", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/libreoffice", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd6yo", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.96, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 29, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "News", + "can_mod_post": False, + "score": 29, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594224961.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "dc82ac98-bafb-11e4-9f88-22000b310327", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2s4nt", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnd6yo", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZero", + "discussion_type": None, + "num_comments": 38, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/libreoffice/comments/hnd6yo/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 4669, + "created_utc": 1594196161.0, + "num_crossposts": 2, + "media": None, + "is_video": False, + } + ], + "created": 1594225018.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": "
      ", + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "7127ec98-5859-11e8-9488-0e8717893ec8", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#0aa18f", + "id": "hnd7cy", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZero", + "discussion_type": None, + "num_comments": 120, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_hnd6yo", + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnd7cy/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 544037, + "created_utc": 1594196218.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + } + ], + "after": "t3_hmytic", + "before": None, + }, +} + +author_mock = { + "kind": "Listing", + "data": { + "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", + "dist": 27, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [{"e": "text", "t": "Popular Application"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd7cy", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.95, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 226, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Popular Application", + "can_mod_post": False, + "score": 226, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [ + { + "approved_at_utc": None, + "subreddit": "libreoffice", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/libreoffice", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd6yo", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.96, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 29, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "News", + "can_mod_post": False, + "score": 29, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594224961.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "dc82ac98-bafb-11e4-9f88-22000b310327", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2s4nt", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnd6yo", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZero", + "discussion_type": None, + "num_comments": 38, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/libreoffice/comments/hnd6yo/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 4669, + "created_utc": 1594196161.0, + "num_crossposts": 2, + "media": None, + "is_video": False, + } + ], + "created": 1594225018.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": "
      ", + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "7127ec98-5859-11e8-9488-0e8717893ec8", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#0aa18f", + "id": "hnd7cy", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZeroTheQuantumZeroTheQuantumZero", + "discussion_type": None, + "num_comments": 120, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_hnd6yo", + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnd7cy/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 544037, + "created_utc": 1594196218.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + } + ], + "after": "t3_hmytic", + "before": None, + }, +} + +title_mock = { + "kind": "Listing", + "data": { + "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", + "dist": 27, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal EditionBoard statement on the LibreOffice 7.0 RC "Personal Edition" label" labelBoard statement on the LibreOffice 7.0 RC "PersBoard statement on the LibreOffice 7.0 RC "Personal Edition" labelonal Edition" label', + "link_flair_richtext": [{"e": "text", "t": "Popular Application"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd7cy", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.95, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 226, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Popular Application", + "can_mod_post": False, + "score": 226, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [ + { + "approved_at_utc": None, + "subreddit": "libreoffice", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/libreoffice", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd6yo", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.96, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 29, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "News", + "can_mod_post": False, + "score": 29, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594224961.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "dc82ac98-bafb-11e4-9f88-22000b310327", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2s4nt", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnd6yo", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZero", + "discussion_type": None, + "num_comments": 38, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/libreoffice/comments/hnd6yo/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 4669, + "created_utc": 1594196161.0, + "num_crossposts": 2, + "media": None, + "is_video": False, + } + ], + "created": 1594225018.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": "
      ", + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "7127ec98-5859-11e8-9488-0e8717893ec8", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#0aa18f", + "id": "hnd7cy", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZeroTheQuantumZeroTheQuantumZero", + "discussion_type": None, + "num_comments": 120, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_hnd6yo", + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnd7cy/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 544037, + "created_utc": 1594196218.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + } + ], + "after": "t3_hmytic", + "before": None, + }, +} diff --git a/src/newsreader/news/collection/tests/reddit/builder/tests.py b/src/newsreader/news/collection/tests/reddit/builder/tests.py new file mode 100644 index 0000000..3085199 --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/builder/tests.py @@ -0,0 +1,185 @@ +from datetime import datetime +from unittest.mock import MagicMock + +from django.test import TestCase + +import pytz + +from newsreader.news.collection.reddit import RedditBuilder +from newsreader.news.collection.tests.factories import SubredditFactory +from newsreader.news.collection.tests.reddit.builder.mocks import ( + author_mock, + empty_mock, + simple_mock, + title_mock, + unknown_mock, + unsanitized_mock, +) +from newsreader.news.core.models import Post +from newsreader.news.core.tests.factories import PostFactory + + +class RedditBuilderTestCase(TestCase): + def setUp(self): + self.maxDiff = None + + def test_simple_mock(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((simple_mock, mock_stream)) as builder: + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual( + ("hm0qct", "hna75r", "hngs71", "hngsj8", "hnd7cy"), posts.keys() + ) + + post = posts["hm0qct"] + + self.assertEquals(post.rule, subreddit) + self.assertEquals( + post.title, + "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", + ) + self.assertIn( + " This megathread is also to hear opinions from anyone just starting out" + " with Linux or those that have used Linux (GNU or otherwise) for a long", + post.body, + ) + + self.assertIn( + "

      For those looking for certifications please use this megathread to ask about how" + " to get certified whether it's for the business world or for your own satisfaction." + ' Be sure to check out r/linuxadmin for more discussion in the' + " SysAdmin world!

      ", + post.body, + ) + + self.assertEquals(post.author, "AutoModerator") + self.assertEquals( + post.url, + "https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", + ) + self.assertEquals( + post.publication_date, pytz.utc.localize(datetime(2020, 7, 6, 6, 11, 22)) + ) + + def test_empty_data(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((empty_mock, mock_stream)) as builder: + builder.save() + + self.assertEquals(Post.objects.count(), 0) + + def test_unknown_mock(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((unknown_mock, mock_stream)) as builder: + builder.save() + + self.assertEquals(Post.objects.count(), 0) + + def test_update_posts(self): + subreddit = SubredditFactory() + existing_publication_date = pytz.utc.localize(datetime(2020, 7, 8, 14, 0, 0)) + existing_post = PostFactory( + remote_identifier="hngsj8", + publication_date=existing_publication_date, + author="Old author", + title="Old title", + body="Old body", + url="https://bbc.com/", + rule=subreddit, + ) + + builder = RedditBuilder + mock_stream = MagicMock(rule=subreddit) + + with builder((simple_mock, mock_stream)) as builder: + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual( + ("hm0qct", "hna75r", "hngs71", "hngsj8", "hnd7cy"), posts.keys() + ) + + existing_post.refresh_from_db() + + self.assertEquals(existing_post.remote_identifier, "hngsj8") + self.assertEquals(existing_post.author, "nixcraft") + self.assertEquals(existing_post.title, "KeePassXC 2.6.0 released") + self.assertEquals(existing_post.body, "") + self.assertEquals( + existing_post.publication_date, + pytz.utc.localize(datetime(2020, 7, 8, 15, 11, 6)), + ) + self.assertEquals( + existing_post.url, + "https://www.reddit.com/r/linux/comments/hngsj8/" "keepassxc_260_released/", + ) + + def test_html_sanitizing(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((unsanitized_mock, mock_stream)) as builder: + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual(("hnd7cy",), posts.keys()) + + post = posts["hnd7cy"] + + self.assertEquals(post.body, "
      ") + + def test_long_author_text_is_truncated(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((author_mock, mock_stream)) as builder: + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual(("hnd7cy",), posts.keys()) + + post = posts["hnd7cy"] + + self.assertEquals(post.author, "TheQuantumZeroTheQuantumZeroTheQuantumZ…") + + def test_long_title_text_is_truncated(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((title_mock, mock_stream)) as builder: + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual(("hnd7cy",), posts.keys()) + + post = posts["hnd7cy"] + + self.assertEquals( + post.title, + 'Board statement on the LibreOffice 7.0 RC "Personal EditionBoard statement on the LibreOffice 7.0 RC "Personal Edition" label" labelBoard statement on the LibreOffice 7.0 RC "PersBoard statement on t…', + ) diff --git a/src/newsreader/news/collection/tests/reddit/client/__init__.py b/src/newsreader/news/collection/tests/reddit/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/reddit/client/mocks.py b/src/newsreader/news/collection/tests/reddit/client/mocks.py new file mode 100644 index 0000000..6a11409 --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/client/mocks.py @@ -0,0 +1,160 @@ +# Note that some response data is truncated + +simple_mock = { + "data": { + "after": "t3_hjywyf", + "before": None, + "children": [ + { + "data": { + "approved_at_utc": None, + "approved_by": None, + "archived": False, + "author": "AutoModerator", + "banned_at_utc": None, + "banned_by": None, + "category": None, + "content_categories": None, + "created": 1593605471.0, + "created_utc": 1593576671.0, + "discussion_type": None, + "distinguished": "moderator", + "domain": "self.linux", + "edited": False, + "hidden": False, + "id": "hj34ck", + "locked": False, + "name": "t3_hj34ck", + "permalink": "/r/linux/comments/hj34ck/weekly_questions_and_hardware_thread_july_01_2020/", + "pinned": False, + "selftext": "Welcome to r/linux! If you're " + "new to Linux or trying to get " + "started this thread is for you. " + "Get help here or as always, " + "check out r/linuxquestions or " + "r/linux4noobs\n" + "\n" + "This megathread is for all your " + "question needs. As we don't " + "allow questions on r/linux " + "outside of this megathread, " + "please consider using " + "r/linuxquestions or " + "r/linux4noobs for the best " + "solution to your problem.\n" + "\n" + "Ask your hardware requests here " + "too or try r/linuxhardware!", + "selftext_html": "<!-- SC_OFF " + "--><div " + 'class="md"><p>Welcome ' + "to <a " + 'href="/r/linux">r/linux</a>! ' + "If you&#39;re new to " + "Linux or trying to get " + "started this thread is for " + "you. Get help here or as " + "always, check out <a " + 'href="/r/linuxquestions">r/linuxquestions</a> ' + "or <a " + 'href="/r/linux4noobs">r/linux4noobs</a></p>\n' + "\n" + "<p>This megathread is " + "for all your question " + "needs. As we don&#39;t " + "allow questions on <a " + 'href="/r/linux">r/linux</a> ' + "outside of this megathread, " + "please consider using <a " + 'href="/r/linuxquestions">r/linuxquestions</a> ' + "or <a " + 'href="/r/linux4noobs">r/linux4noobs</a> ' + "for the best solution to " + "your problem.</p>\n" + "\n" + "<p>Ask your hardware " + "requests here too or try " + "<a " + 'href="/r/linuxhardware">r/linuxhardware</a>!</p>\n' + "</div><!-- SC_ON " + "-->", + "spoiler": False, + "stickied": True, + "subreddit": "linux", + "subreddit_id": "t5_2qh1a", + "subreddit_name_prefixed": "r/linux", + "title": "Weekly Questions and Hardware " "Thread - July 01, 2020", + "url": "https://www.reddit.com/r/linux/comments/hj34ck/weekly_questions_and_hardware_thread_july_01_2020/", + "visited": False, + }, + "kind": "t3", + }, + { + "data": { + "archived": False, + "author": "AutoModerator", + "banned_at_utc": None, + "banned_by": None, + "category": None, + "created": 1593824903.0, + "created_utc": 1593796103.0, + "discussion_type": None, + "domain": "self.linux", + "edited": False, + "hidden": False, + "id": "hkmu0t", + "name": "t3_hkmu0t", + "permalink": "/r/linux/comments/hkmu0t/weekend_fluff_linux_in_the_wild_thread_july_03/", + "pinned": False, + "saved": False, + "selftext": "Welcome to the weekend! This " + "stickied thread is for you to " + "post pictures of your ubuntu " + "2006 install disk, slackware " + "floppies, on-topic memes or " + "more.\n" + "\n" + "When it's not the weekend, be " + "sure to check out " + "r/WildLinuxAppears or " + "r/linuxmemes!", + "selftext_html": "<!-- SC_OFF " + "--><div " + 'class="md"><p>Welcome ' + "to the weekend! This " + "stickied thread is for you " + "to post pictures of your " + "ubuntu 2006 install disk, " + "slackware floppies, " + "on-topic memes or " + "more.</p>\n" + "\n" + "<p>When it&#39;s " + "not the weekend, be sure to " + "check out <a " + 'href="/r/WildLinuxAppears">r/WildLinuxAppears</a> ' + "or <a " + 'href="/r/linuxmemes">r/linuxmemes</a>!</p>\n' + "</div><!-- SC_ON " + "-->", + "spoiler": False, + "stickied": True, + "subreddit": "linux", + "subreddit_id": "t5_2qh1a", + "subreddit_name_prefixed": "r/linux", + "subreddit_subscribers": 542073, + "subreddit_type": "public", + "thumbnail": "", + "title": "Weekend Fluff / Linux in the Wild " + "Thread - July 03, 2020", + "url": "https://www.reddit.com/r/linux/comments/hkmu0t/weekend_fluff_linux_in_the_wild_thread_july_03/", + "visited": False, + }, + "kind": "t3", + }, + ], + "dist": 27, + "modhash": None, + }, + "kind": "Listing", +} diff --git a/src/newsreader/news/collection/tests/reddit/client/tests.py b/src/newsreader/news/collection/tests/reddit/client/tests.py new file mode 100644 index 0000000..f2ee84d --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/client/tests.py @@ -0,0 +1,164 @@ +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +from django.test import TestCase +from django.utils.lorem_ipsum import words + +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamException, + StreamNotFoundException, + StreamParseException, + StreamTimeOutException, + StreamTooManyException, +) +from newsreader.news.collection.reddit import RedditClient +from newsreader.news.collection.tests.factories import SubredditFactory + +from .mocks import simple_mock + + +class RedditClientTestCase(TestCase): + def setUp(self): + self.maxDiff = None + + self.patched_read = patch("newsreader.news.collection.reddit.RedditStream.read") + self.mocked_read = self.patched_read.start() + + def tearDown(self): + patch.stopall() + + def test_client_retrieves_single_rules(self): + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + self.mocked_read.return_value = (simple_mock, mock_stream) + + with RedditClient([[subreddit]]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertEquals(data, simple_mock) + self.assertEquals(stream, mock_stream) + + self.mocked_read.assert_called_once_with() + + def test_client_catches_stream_exception(self): + subreddit = SubredditFactory() + + self.mocked_read.side_effect = StreamException(message="Stream exception") + + with RedditClient([[subreddit]]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertEquals(data, None) + self.assertEquals(stream, None) + self.assertEquals(stream.rule.error, "Stream exception") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called_once_with() + + def test_client_catches_stream_not_found_exception(self): + subreddit = SubredditFactory.create() + + self.mocked_read.side_effect = StreamNotFoundException( + message="Stream not found" + ) + + with RedditClient([[subreddit]]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertEquals(data, None) + self.assertEquals(stream, None) + self.assertEquals(stream.rule.error, "Stream not found") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called_once_with() + + @patch("newsreader.news.collection.reddit.RedditTokenTask") + def test_client_catches_stream_denied_exception(self, mocked_task): + user = UserFactory( + reddit_access_token=str(uuid4()), reddit_refresh_token=str(uuid4()) + ) + subreddit = SubredditFactory(user=user) + + self.mocked_read.side_effect = StreamDeniedException(message="Token expired") + + with RedditClient([(subreddit,)]) as client: + results = [(data, stream) for data, stream in client] + + self.mocked_read.assert_called_once_with() + mocked_task.delay.assert_called_once_with(user.pk) + + self.assertEquals(len(results), 0) + + user.refresh_from_db() + subreddit.refresh_from_db() + + self.assertEquals(user.reddit_access_token, None) + self.assertEquals(subreddit.succeeded, False) + self.assertEquals(subreddit.error, "Token expired") + + def test_client_catches_stream_timed_out_exception(self): + subreddit = SubredditFactory() + + self.mocked_read.side_effect = StreamTimeOutException( + message="Stream timed out" + ) + + with RedditClient([[subreddit]]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertEquals(data, None) + self.assertEquals(stream, None) + self.assertEquals(stream.rule.error, "Stream timed out") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called_once_with() + + def test_client_catches_stream_too_many_exception(self): + subreddit = SubredditFactory() + + self.mocked_read.side_effect = StreamTooManyException + + with RedditClient([[subreddit]]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertEquals(data, None) + self.assertEquals(stream, None) + self.assertEquals(stream.rule.error, "Too many requests") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called_once_with() + + def test_client_catches_stream_parse_exception(self): + subreddit = SubredditFactory() + + self.mocked_read.side_effect = StreamParseException( + message="Stream could not be parsed" + ) + + with RedditClient([[subreddit]]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertEquals(data, None) + self.assertEquals(stream, None) + self.assertEquals(stream.rule.error, "Stream could not be parsed") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called_once_with() + + def test_client_catches_long_exception_text(self): + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + self.mocked_read.side_effect = StreamParseException(message=words(1000)) + + with RedditClient([[subreddit]]) as client: + for data, stream in client: + self.assertEquals(data, None) + self.assertEquals(stream, None) + self.assertEquals(len(stream.rule.error), 1024) + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called_once_with() diff --git a/src/newsreader/news/collection/tests/reddit/collector/__init__.py b/src/newsreader/news/collection/tests/reddit/collector/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/reddit/collector/mocks.py b/src/newsreader/news/collection/tests/reddit/collector/mocks.py new file mode 100644 index 0000000..37d40d8 --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/collector/mocks.py @@ -0,0 +1,1662 @@ +simple_mock_1 = { + "kind": "Listing", + "data": { + "modhash": "khwcr8tmp613f1b92d55150adb744983e7f6c37e87e30f6432", + "dist": 26, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "starcitizen", + "selftext": "Welcome to the Star Citizen question and answer thread. Feel free to ask any questions you have related to SC here!\r\n\r\n---\r\n\r\nUseful Links and Resources:\r\n\r\n[Star Citizen Wiki](https://starcitizen.tools) - *The biggest and best wiki resource dedicated to Star Citizen*\r\n\r\n[Star Citizen FAQ](https://starcitizen.tools/Frequently_Asked_Questions) - *Chances the answer you need is here.* \r\n\r\n[Discord Help Channel](https://discord.gg/0STCP5tSe7x9NBSq) - *Often times community members will be here to help you with issues.*\r\n\r\n[Referral Code Randomizer](http://gorefer.me/starcitizen) - *Use this when creating a new account to get 5000 extra UEC.*\r\n\r\n[Download Star Citizen](https://robertsspaceindustries.com/download) - *Get the latest version of Star Citizen here*\r\n\r\n[Current Game Features](https://robertsspaceindustries.com/feature-list) - *Click here to see what you can currently do in Star Citizen.*\r\n\r\n[Development Roadmap](https://robertsspaceindustries.com/roadmap/board/1-Star-Citizen) - *The current development status of up and coming Star Citizen features.*\r\n\r\n[Pledge FAQ](https://support.robertsspaceindustries.com/hc/en-us/articles/115013194987-Pledges-FAQs) - *Official FAQ regarding spending money on the game.*", + "author_fullname": "t2_otk50", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Star Citizen: Question and Answer Thread", + "link_flair_richtext": [{"e": "text", "t": "QUESTION"}], + "subreddit_name_prefixed": "r/starcitizen", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "QUESTION", + "downs": 0, + "thumbnail_height": None, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hm6byg", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.9, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 21, + "total_awards_received": 0, + "media_embed": {}, + "thumbnail_width": None, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "QUESTION", + "can_mod_post": False, + "score": 21, + "approved_by": None, + "author_premium": False, + "thumbnail": "self", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "post_hint": "self", + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594065605, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.starcitizen", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>Welcome to the Star Citizen question and answer thread. Feel free to ask any questions you have related to SC here!</p>\n\n<hr/>\n\n<p>Useful Links and Resources:</p>\n\n<p><a href="https://starcitizen.tools">Star Citizen Wiki</a> - <em>The biggest and best wiki resource dedicated to Star Citizen</em></p>\n\n<p><a href="https://starcitizen.tools/Frequently_Asked_Questions">Star Citizen FAQ</a> - <em>Chances the answer you need is here.</em> </p>\n\n<p><a href="https://discord.gg/0STCP5tSe7x9NBSq">Discord Help Channel</a> - <em>Often times community members will be here to help you with issues.</em></p>\n\n<p><a href="http://gorefer.me/starcitizen">Referral Code Randomizer</a> - <em>Use this when creating a new account to get 5000 extra UEC.</em></p>\n\n<p><a href="https://robertsspaceindustries.com/download">Download Star Citizen</a> - <em>Get the latest version of Star Citizen here</em></p>\n\n<p><a href="https://robertsspaceindustries.com/feature-list">Current Game Features</a> - <em>Click here to see what you can currently do in Star Citizen.</em></p>\n\n<p><a href="https://robertsspaceindustries.com/roadmap/board/1-Star-Citizen">Development Roadmap</a> - <em>The current development status of up and coming Star Citizen features.</em></p>\n\n<p><a href="https://support.robertsspaceindustries.com/hc/en-us/articles/115013194987-Pledges-FAQs">Pledge FAQ</a> - <em>Official FAQ regarding spending money on the game.</em></p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": "new", + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "preview": { + "images": [ + { + "source": { + "url": "https://external-preview.redd.it/5qLIX6ObbErxkDiTz24uP6TbZGNVNyy2SeXusPhzaKA.jpg?auto=webp&s=738b5270a81373916191470a1da34cdcc54d8511", + "width": 332, + "height": 360, + }, + "resolutions": [ + { + "url": "https://external-preview.redd.it/5qLIX6ObbErxkDiTz24uP6TbZGNVNyy2SeXusPhzaKA.jpg?width=108&crop=smart&auto=webp&s=e2ee2a9dae15472663b52c8cb4e002fdbbb6378c", + "width": 108, + "height": 117, + }, + { + "url": "https://external-preview.redd.it/5qLIX6ObbErxkDiTz24uP6TbZGNVNyy2SeXusPhzaKA.jpg?width=216&crop=smart&auto=webp&s=3690c60a9b533d376f159f306c6667b47ff42102", + "width": 216, + "height": 234, + }, + { + "url": "https://external-preview.redd.it/5qLIX6ObbErxkDiTz24uP6TbZGNVNyy2SeXusPhzaKA.jpg?width=320&crop=smart&auto=webp&s=4dcb434a5071329ecbb9f3543e4d06442ab141df", + "width": 320, + "height": 346, + }, + ], + "variants": {}, + "id": "KTE3H6RnWCasOJCFtdmgmw51FMzxSqXz_SRD6W5Rdsc", + } + ], + "enabled": False, + }, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2v94d", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hm6byg", + "is_robot_indexable": True, + "report_reasons": None, + "author": "UEE_Central_Computer", + "discussion_type": None, + "num_comments": 380, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/starcitizen/comments/hm6byg/star_citizen_question_and_answer_thread/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/starcitizen/comments/hm6byg/star_citizen_question_and_answer_thread/", + "subreddit_subscribers": 213071, + "created_utc": 1594036805, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "starcitizen", + "selftext": "", + "author_fullname": "t2_6wgp9w28", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "5 random people in a train felt like such a rare and special thing 😁", + "link_flair_richtext": [{"e": "text", "t": "FLUFF"}], + "subreddit_name_prefixed": "r/starcitizen", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "fluff", + "downs": 0, + "thumbnail_height": 78, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hpkhgj", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.98, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 892, + "total_awards_received": 0, + "media_embed": {}, + "thumbnail_width": 140, + "author_flair_template_id": "a87724f8-c2b5-11e4-b7e0-22000b2103f6", + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "FLUFF", + "can_mod_post": False, + "score": 892, + "approved_by": None, + "author_premium": False, + "thumbnail": "https://b.thumbs.redditmedia.com/YlF6BTm-DfnrZBeukYiOyrP-Fkj2xUQtk_V8ZeUD93w.jpg", + "edited": False, + "author_flair_css_class": "aurora", + "author_flair_richtext": [ + {"e": "text", "t": "🌌2013Backer🎮vGameDev🌌"} + ], + "gildings": {}, + "post_hint": "image", + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594540209, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/0jkge020fba51.png", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "preview": { + "images": [ + { + "source": { + "url": "https://preview.redd.it/0jkge020fba51.png?auto=webp&s=c3a2b8cb860f839638a364d49abca04fd4f42094", + "width": 2560, + "height": 1440, + }, + "resolutions": [ + { + "url": "https://preview.redd.it/0jkge020fba51.png?width=108&crop=smart&auto=webp&s=778a7f7d9b2e0d713161e84b32c467ebde6cbc17", + "width": 108, + "height": 60, + }, + { + "url": "https://preview.redd.it/0jkge020fba51.png?width=216&crop=smart&auto=webp&s=53afc50cc2dd6c72470e76a4c3ff8ef597f66e0d", + "width": 216, + "height": 121, + }, + { + "url": "https://preview.redd.it/0jkge020fba51.png?width=320&crop=smart&auto=webp&s=089f9ff42e429b5062c143695e695cbb4ea5b679", + "width": 320, + "height": 180, + }, + { + "url": "https://preview.redd.it/0jkge020fba51.png?width=640&crop=smart&auto=webp&s=045327ac6fd113630c0faef426d86efaf04f55e2", + "width": 640, + "height": 360, + }, + { + "url": "https://preview.redd.it/0jkge020fba51.png?width=960&crop=smart&auto=webp&s=efbdc9ddcda1207fafa20bb45e82fbe24ed37df8", + "width": 960, + "height": 540, + }, + { + "url": "https://preview.redd.it/0jkge020fba51.png?width=1080&crop=smart&auto=webp&s=1b94c9951c60a788357dfa0fe21dd983efdcf1e7", + "width": 1080, + "height": 607, + }, + ], + "variants": {}, + "id": "r-JjrJn0RtZLaxMk_d-TCfW80pWgJ-5kjMaje54J5_I", + } + ], + "enabled": True, + }, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "db099dc4-3538-11e5-97ec-0e7f0fa558f9", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": "🌌2013Backer🎮vGameDev🌌", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2v94d", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#007373", + "id": "hpkhgj", + "is_robot_indexable": True, + "report_reasons": None, + "author": "Y_DK_Y", + "discussion_type": None, + "num_comments": 39, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/starcitizen/comments/hpkhgj/5_random_people_in_a_train_felt_like_such_a_rare/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/0jkge020fba51.png", + "subreddit_subscribers": 213071, + "created_utc": 1594511409, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "starcitizen", + "selftext": "", + "author_fullname": "t2_4brylpu5", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Drake Interplanetary Smartkey thing that I made!", + "link_flair_richtext": [{"e": "text", "t": "ARTWORK"}], + "subreddit_name_prefixed": "r/starcitizen", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "artwork", + "downs": 0, + "thumbnail_height": 78, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hph00n", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.97, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 547, + "total_awards_received": 1, + "media_embed": {}, + "thumbnail_width": 140, + "author_flair_template_id": None, + "is_original_content": True, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "ARTWORK", + "can_mod_post": False, + "score": 547, + "approved_by": None, + "author_premium": False, + "thumbnail": "https://b.thumbs.redditmedia.com/gr7RYEjNN5FNc42LxuizFW_ZxWtS3xbZj1QfhIa-2Hw.jpg", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "post_hint": "image", + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594527804, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/b6h74eljeaa51.png", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "preview": { + "images": [ + { + "source": { + "url": "https://preview.redd.it/b6h74eljeaa51.png?auto=webp&s=fd286c2dcd98378c34fde6e245cf13c357716dca", + "width": 1920, + "height": 1080, + }, + "resolutions": [ + { + "url": "https://preview.redd.it/b6h74eljeaa51.png?width=108&crop=smart&auto=webp&s=3150c2a2643d178eba735cb0bc222b8b29f46c8c", + "width": 108, + "height": 60, + }, + { + "url": "https://preview.redd.it/b6h74eljeaa51.png?width=216&crop=smart&auto=webp&s=9120ce40ce7439ca4d3431da7782a8c6acd2eebf", + "width": 216, + "height": 121, + }, + { + "url": "https://preview.redd.it/b6h74eljeaa51.png?width=320&crop=smart&auto=webp&s=83cd5c93fe7a19e5643df38eec3aefee54912faf", + "width": 320, + "height": 180, + }, + { + "url": "https://preview.redd.it/b6h74eljeaa51.png?width=640&crop=smart&auto=webp&s=b3e280a4a7fbaf794692c01f4ff63af0b8559700", + "width": 640, + "height": 360, + }, + { + "url": "https://preview.redd.it/b6h74eljeaa51.png?width=960&crop=smart&auto=webp&s=8ebac203688ba0e42c7975f3d7688dab25fc065b", + "width": 960, + "height": 540, + }, + { + "url": "https://preview.redd.it/b6h74eljeaa51.png?width=1080&crop=smart&auto=webp&s=8350e0b4e004820ef9f30501397d49a2121186ec", + "width": 1080, + "height": 607, + }, + ], + "variants": {}, + "id": "B2HxXfFibxKUtHO9eBwT-Bt_VrE870XhC0R5OFA95rI", + } + ], + "enabled": True, + }, + "all_awardings": [ + { + "giver_coin_reward": 0, + "subreddit_id": None, + "is_new": False, + "days_of_drip_extension": 0, + "coin_price": 50, + "id": "award_02d9ab2c-162e-4c01-8438-317a016ed3d9", + "penny_donate": 0, + "award_sub_type": "GLOBAL", + "coin_reward": 0, + "icon_url": "https://i.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png", + "days_of_premium": 0, + "resized_icons": [ + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=16&height=16&auto=webp&s=92e96be1dbd278dc987fbd9acc1bd5078566f254", + "width": 16, + "height": 16, + }, + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=32&height=32&auto=webp&s=83e14655f2b162b295f7d2c7058b9ad94cf8b73c", + "width": 32, + "height": 32, + }, + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=48&height=48&auto=webp&s=83038a4d6181d3c8f5107dbca4ddb735ca6c2231", + "width": 48, + "height": 48, + }, + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=64&height=64&auto=webp&s=3c4e39a7664d799ff50f32e9a3f96c3109d2e266", + "width": 64, + "height": 64, + }, + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=128&height=128&auto=webp&s=390bf9706b8e1a6215716ebcf6363373f125c339", + "width": 128, + "height": 128, + }, + ], + "icon_width": 2048, + "static_icon_width": 2048, + "start_date": None, + "is_enabled": True, + "description": "I'm in this with you.", + "end_date": None, + "subreddit_coin_reward": 0, + "count": 1, + "static_icon_height": 2048, + "name": "Take My Energy", + "resized_static_icons": [ + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=16&height=16&auto=webp&s=92e96be1dbd278dc987fbd9acc1bd5078566f254", + "width": 16, + "height": 16, + }, + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=32&height=32&auto=webp&s=83e14655f2b162b295f7d2c7058b9ad94cf8b73c", + "width": 32, + "height": 32, + }, + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=48&height=48&auto=webp&s=83038a4d6181d3c8f5107dbca4ddb735ca6c2231", + "width": 48, + "height": 48, + }, + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=64&height=64&auto=webp&s=3c4e39a7664d799ff50f32e9a3f96c3109d2e266", + "width": 64, + "height": 64, + }, + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=128&height=128&auto=webp&s=390bf9706b8e1a6215716ebcf6363373f125c339", + "width": 128, + "height": 128, + }, + ], + "icon_format": "PNG", + "icon_height": 2048, + "penny_price": 0, + "award_type": "global", + "static_icon_url": "https://i.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png", + } + ], + "awarders": [], + "media_only": False, + "link_flair_template_id": "e3bb68b2-3538-11e5-bf5a-0e09b4299f63", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2v94d", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#ff66ac", + "id": "hph00n", + "is_robot_indexable": True, + "report_reasons": None, + "author": "HannahB888", + "discussion_type": None, + "num_comments": 38, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/starcitizen/comments/hph00n/drake_interplanetary_smartkey_thing_that_i_made/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/b6h74eljeaa51.png", + "subreddit_subscribers": 213071, + "created_utc": 1594499004, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "starcitizen", + "selftext": "", + "author_fullname": "t2_exlc6", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "A Historical Moment for CIG", + "link_flair_richtext": [{"e": "text", "t": "FLUFF"}], + "subreddit_name_prefixed": "r/starcitizen", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "fluff", + "downs": 0, + "thumbnail_height": 37, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hp9mlw", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.98, + "author_flair_background_color": "", + "subreddit_type": "public", + "ups": 1444, + "total_awards_received": 0, + "media_embed": {}, + "thumbnail_width": 140, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "FLUFF", + "can_mod_post": False, + "score": 1444, + "approved_by": None, + "author_premium": False, + "thumbnail": "https://b.thumbs.redditmedia.com/YYdiE2x8fsn0ckVJiGCnBzUIOa1DA03ALh3TJuVlZks.jpg", + "edited": False, + "author_flair_css_class": "carrack", + "author_flair_richtext": [{"e": "text", "t": "AHV Artemis"}], + "gildings": {}, + "post_hint": "image", + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594501406, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/fdh2ujp388a51.png", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "preview": { + "images": [ + { + "source": { + "url": "https://preview.redd.it/fdh2ujp388a51.png?auto=webp&s=605044c2757c1b5ca9060d3ec448090396a2f0dd", + "width": 424, + "height": 114, + }, + "resolutions": [ + { + "url": "https://preview.redd.it/fdh2ujp388a51.png?width=108&crop=smart&auto=webp&s=9789c6b76d45e46645fe2454555bfbd042a39815", + "width": 108, + "height": 29, + }, + { + "url": "https://preview.redd.it/fdh2ujp388a51.png?width=216&crop=smart&auto=webp&s=3f419183835c883f10b1caab3a7ecbec4ebbf3ec", + "width": 216, + "height": 58, + }, + { + "url": "https://preview.redd.it/fdh2ujp388a51.png?width=320&crop=smart&auto=webp&s=695ff914462b5b9bc253ce26f4a51f5f22641148", + "width": 320, + "height": 86, + }, + ], + "variants": {}, + "id": "XWdU5CBWG0-5mOzBRF65OnvZzQm2Btd2ldGMeJ8u_gI", + } + ], + "enabled": True, + }, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "db099dc4-3538-11e5-97ec-0e7f0fa558f9", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": "AHV Artemis", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2v94d", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#007373", + "id": "hp9mlw", + "is_robot_indexable": True, + "report_reasons": None, + "author": "sam00197", + "discussion_type": None, + "num_comments": 194, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/starcitizen/comments/hp9mlw/a_historical_moment_for_cig/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/fdh2ujp388a51.png", + "subreddit_subscribers": 213071, + "created_utc": 1594472606, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "starcitizen", + "selftext": "", + "author_fullname": "t2_4dgjlpn7", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "This view. What's your favorite moon?", + "link_flair_richtext": [{"e": "text", "t": "DISCUSSION"}], + "subreddit_name_prefixed": "r/starcitizen", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "discussion", + "downs": 0, + "thumbnail_height": 78, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hpjn8x", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.96, + "author_flair_background_color": "", + "subreddit_type": "public", + "ups": 182, + "total_awards_received": 0, + "media_embed": {}, + "thumbnail_width": 140, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "DISCUSSION", + "can_mod_post": False, + "score": 182, + "approved_by": None, + "author_premium": False, + "thumbnail": "https://a.thumbs.redditmedia.com/tKHL_2fn4Zo9FhrtP3UiJlQA7xkMU7-iN0ntJbhfa80.jpg", + "edited": False, + "author_flair_css_class": "", + "author_flair_richtext": [{"e": "text", "t": "new user/low karma"}], + "gildings": {}, + "post_hint": "image", + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594537150, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/ovly7f9g6ba51.jpg", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "preview": { + "images": [ + { + "source": { + "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?auto=webp&s=d7051e4c713e39c642c583e5e8ada57c9660fa26", + "width": 2560, + "height": 1440, + }, + "resolutions": [ + { + "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?width=108&crop=smart&auto=webp&s=35f6ebe4531c12bc24532f01741bcf8100d954b2", + "width": 108, + "height": 60, + }, + { + "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?width=216&crop=smart&auto=webp&s=a939922e34cf4ff6a82eeb22e71acb816ccc6d7b", + "width": 216, + "height": 121, + }, + { + "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?width=320&crop=smart&auto=webp&s=9796767ed73e04a774d2f1ba8cf3662bbd4195eb", + "width": 320, + "height": 180, + }, + { + "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?width=640&crop=smart&auto=webp&s=37fe4c262b752cb8dac903daf606be8f0ac3b44f", + "width": 640, + "height": 360, + }, + { + "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?width=960&crop=smart&auto=webp&s=305245fd1d352634c86459131b11238fe09f5d2b", + "width": 960, + "height": 540, + }, + { + "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?width=1080&crop=smart&auto=webp&s=e8438e4b666cf616646ffad09c153d120df1f1d9", + "width": 1080, + "height": 607, + }, + ], + "variants": {}, + "id": "SjRqA5h_B55WLnwAlocF6wcxIHZLgGBMpmb5nV1EQ4E", + } + ], + "enabled": True, + }, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "ca858044-1916-11e2-a9b9-12313d168e98", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": "new user/low karma", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2v94d", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#014980", + "id": "hpjn8x", + "is_robot_indexable": True, + "report_reasons": None, + "author": "clericanubis", + "discussion_type": None, + "num_comments": 27, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/starcitizen/comments/hpjn8x/this_view_whats_your_favorite_moon/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/ovly7f9g6ba51.jpg", + "subreddit_subscribers": 213071, + "created_utc": 1594508350, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + ], + "after": "t3_hplinp", + "before": None, + }, +} + +simple_mock_2 = { + "kind": "Listing", + "data": { + "modhash": "y4he8gfzh9f892e2bf3094bc06daba2e02288e617fecf555b5", + "dist": 27, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "Python", + "selftext": "Top Level comments must be **Job Opportunities.**\n\nPlease include **Location** or any other **Requirements** in your comment. If you require people to work on site in San Francisco, *you must note that in your post.* If you require an Engineering degree, *you must note that in your post*.\n\nPlease include as much information as possible.\n\nIf you are looking for jobs, send a PM to the poster.", + "author_fullname": "t2_628u", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "/r/Python Job Board for May, June, July", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/Python", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_gdfaip", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.98, + "author_flair_background_color": "", + "subreddit_type": "public", + "ups": 108, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 108, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": "", + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1588640187, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.Python", + "allow_live_comments": True, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>Top Level comments must be <strong>Job Opportunities.</strong></p>\n\n<p>Please include <strong>Location</strong> or any other <strong>Requirements</strong> in your comment. If you require people to work on site in San Francisco, <em>you must note that in your post.</em> If you require an Engineering degree, <em>you must note that in your post</em>.</p>\n\n<p>Please include as much information as possible.</p>\n\n<p>If you are looking for jobs, send a PM to the poster.</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": "reticulated", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh0y", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "gdfaip", + "is_robot_indexable": True, + "report_reasons": None, + "author": "aphoenix", + "discussion_type": None, + "num_comments": 38, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/Python/comments/gdfaip/rpython_job_board_for_may_june_july/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/Python/comments/gdfaip/rpython_job_board_for_may_june_july/", + "subreddit_subscribers": 616297, + "created_utc": 1588611387, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "Python", + "selftext": "# EDIT: AMA complete. Huge thanks to the PyCharm Team for holding this!\n\nAs mentioned in the comments you can use code `reddit20202` at [https://www.jetbrains.com/store/redeem/](https://www.jetbrains.com/store/redeem/) to try out PyCharm Professional as a new JetBrains customer!\n\nWe will be joined by members of the PyCharm Developer team from JetBrains to answer all sorts of questions on the PyCharm IDE and the Python language!\n\n[PyCharm](https://www.jetbrains.com/pycharm/) is the professional IDE for Python Developers with over 33% of respondents from the [2019 Python Developers Survey](https://www.jetbrains.com/lp/python-developers-survey-2019/) choosing it as their main editor.\n\nPyCharm features smart autocompletion, on-the-fly error checking and quick fixes as well as PEP8 compliance detection and automatic refactoring.\n\nIf you haven't checked out PyCharm then you definitely should, the Community Edition of PyCharm includes many key features such as the debugger, test runners, intelligent code completion and more!\n\nIf you are looking for a professional IDE for Python then the PyCharm Professional edition adds features such as advanced web development tools and database/SQL support, if you are a student or maintain an open source project make sure to take a look at the generous discounts JetBrains offer for their products!\n\nThe AMA will begin at 16:00 UTC on the 9th of July. Feel free to drop questions below for the PyCharm team to answer!\n\nWe will be joined by:\n\n* Nafiul Islam, u/nafiulislamjb (Developer Advocate for PyCharm)\n* Andrey Vlasovskikh, u/vlasovskikh (PyCharm Team Lead)", + "user_reports": [], + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "AMA with PyCharm team from JetBrains on 9th July @ 16:00 UTC", + "event_start": 1594310400, + "subreddit_name_prefixed": "r/Python", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "editors", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hmd2ez", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.94, + "author_flair_background_color": "", + "subreddit_type": "public", + "ups": 60, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "author_fullname": "t2_145f96", + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Editors / IDEs", + "can_mod_post": False, + "score": 60, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": 1594321779, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594088635, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.Python", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><h1>EDIT: AMA complete. Huge thanks to the PyCharm Team for holding this!</h1>\n\n<p>As mentioned in the comments you can use code <code>reddit20202</code> at <a href="https://www.jetbrains.com/store/redeem/">https://www.jetbrains.com/store/redeem/</a> to try out PyCharm Professional as a new JetBrains customer!</p>\n\n<p>We will be joined by members of the PyCharm Developer team from JetBrains to answer all sorts of questions on the PyCharm IDE and the Python language!</p>\n\n<p><a href="https://www.jetbrains.com/pycharm/">PyCharm</a> is the professional IDE for Python Developers with over 33% of respondents from the <a href="https://www.jetbrains.com/lp/python-developers-survey-2019/">2019 Python Developers Survey</a> choosing it as their main editor.</p>\n\n<p>PyCharm features smart autocompletion, on-the-fly error checking and quick fixes as well as PEP8 compliance detection and automatic refactoring.</p>\n\n<p>If you haven&#39;t checked out PyCharm then you definitely should, the Community Edition of PyCharm includes many key features such as the debugger, test runners, intelligent code completion and more!</p>\n\n<p>If you are looking for a professional IDE for Python then the PyCharm Professional edition adds features such as advanced web development tools and database/SQL support, if you are a student or maintain an open source project make sure to take a look at the generous discounts JetBrains offer for their products!</p>\n\n<p>The AMA will begin at 16:00 UTC on the 9th of July. Feel free to drop questions below for the PyCharm team to answer!</p>\n\n<p>We will be joined by:</p>\n\n<ul>\n<li>Nafiul Islam, <a href="/u/nafiulislamjb">u/nafiulislamjb</a> (Developer Advocate for PyCharm)</li>\n<li>Andrey Vlasovskikh, <a href="/u/vlasovskikh">u/vlasovskikh</a> (PyCharm Team Lead)</li>\n</ul>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": "confidence", + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "49f2747c-4114-11ea-b9fe-0e741fe75651", + "link_flair_richtext": [], + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": "Owner of Python Discord", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": "moderator", + "subreddit_id": "t5_2qh0y", + "event_end": 1594324800, + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "event_is_live": False, + "id": "hmd2ez", + "is_robot_indexable": True, + "report_reasons": None, + "author": "Im__Joseph", + "discussion_type": None, + "num_comments": 65, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/Python/comments/hmd2ez/ama_with_pycharm_team_from_jetbrains_on_9th_july/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/Python/comments/hmd2ez/ama_with_pycharm_team_from_jetbrains_on_9th_july/", + "subreddit_subscribers": 616297, + "created_utc": 1594059835, + "num_crossposts": 2, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "Python", + "selftext": "", + "author_fullname": "t2_woll6", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "I am a medical student, and I recently programmed an open-source eye-tracker for brain research", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/Python", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "made-this", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hpr28u", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.99, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 439, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": "4cc838b8-3159-11e1-83e4-12313d18ad57", + "is_original_content": False, + "user_reports": [], + "secure_media": { + "reddit_video": { + "fallback_url": "https://v.redd.it/tqzx750wzda51/DASH_360.mp4?source=fallback", + "height": 384, + "width": 512, + "scrubber_media_url": "https://v.redd.it/tqzx750wzda51/DASH_96.mp4", + "dash_url": "https://v.redd.it/tqzx750wzda51/DASHPlaylist.mpd?a=1597142191%2CY2JkNmU5Y2FmZGM1NzA5MjhkYTk5NjdmMWRmNWI4M2I2N2Q2MjA5NmIzZWRmODJiMjk0MzY4OTZlYTBiZmZlZg%3D%3D&v=1&f=sd", + "duration": 31, + "hls_url": "https://v.redd.it/tqzx750wzda51/HLSPlaylist.m3u8?a=1597142191%2CZDVhNWNjMGQ0OTBjOTU0Zjk5MDgwZmE2YzA1MGY5YzNlZThmZTAxZTgxODIxMGFjZDdlYzczOWFlYTcyMmMzNg%3D%3D&v=1&f=sd", + "is_gif": False, + "transcoding_status": "completed", + } + }, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "I Made This", + "can_mod_post": False, + "score": 439, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594571350, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "v.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://v.redd.it/tqzx750wzda51", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "d7dfae22-4113-11ea-b9fe-0e741fe75651", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": "Neuroscientist", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh0y", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hpr28u", + "is_robot_indexable": True, + "report_reasons": None, + "author": "Sebaron", + "discussion_type": None, + "num_comments": 33, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/Python/comments/hpr28u/i_am_a_medical_student_and_i_recently_programmed/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://v.redd.it/tqzx750wzda51", + "subreddit_subscribers": 616297, + "created_utc": 1594542550, + "num_crossposts": 0, + "media": { + "reddit_video": { + "fallback_url": "https://v.redd.it/tqzx750wzda51/DASH_360.mp4?source=fallback", + "height": 384, + "width": 512, + "scrubber_media_url": "https://v.redd.it/tqzx750wzda51/DASH_96.mp4", + "dash_url": "https://v.redd.it/tqzx750wzda51/DASHPlaylist.mpd?a=1597142191%2CY2JkNmU5Y2FmZGM1NzA5MjhkYTk5NjdmMWRmNWI4M2I2N2Q2MjA5NmIzZWRmODJiMjk0MzY4OTZlYTBiZmZlZg%3D%3D&v=1&f=sd", + "duration": 31, + "hls_url": "https://v.redd.it/tqzx750wzda51/HLSPlaylist.m3u8?a=1597142191%2CZDVhNWNjMGQ0OTBjOTU0Zjk5MDgwZmE2YzA1MGY5YzNlZThmZTAxZTgxODIxMGFjZDdlYzczOWFlYTcyMmMzNg%3D%3D&v=1&f=sd", + "is_gif": False, + "transcoding_status": "completed", + } + }, + "is_video": True, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "Python", + "selftext": "", + "author_fullname": "t2_6zgzj94n", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "I made a filename simplifier which removes unnecessary tags, metadata, dashes, dots, underscores, and non-English characters from filenames (and folders) to give your library a neat look.", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/Python", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "made-this", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hpps6f", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.95, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 258, + "total_awards_received": 1, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": { + "reddit_video": { + "fallback_url": "https://v.redd.it/jq229anzada51/DASH_1080.mp4?source=fallback", + "height": 1080, + "width": 1920, + "scrubber_media_url": "https://v.redd.it/jq229anzada51/DASH_96.mp4", + "dash_url": "https://v.redd.it/jq229anzada51/DASHPlaylist.mpd?a=1597142191%2CZDU4Y2FmYzI2NjMzZTMxNzJkOThiMzJmYzBlOTMyMmEwNTg3MTFhMmU0OWZjZDljZGQ4MjAwMTgxMGVhYzU1OQ%3D%3D&v=1&f=sd", + "duration": 27, + "hls_url": "https://v.redd.it/jq229anzada51/HLSPlaylist.m3u8?a=1597142191%2CYmY1Y2Q5ZjQ0ZWVmODAxODQ3MGU3YzA1YzIxOTEzODFlNWQyMjE4MzAyYzNiMDM5NTI0N2M5OTRmY2YwN2NlOA%3D%3D&v=1&f=sd", + "is_gif": False, + "transcoding_status": "completed", + } + }, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "I Made This", + "can_mod_post": False, + "score": 258, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594563987, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "v.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://v.redd.it/jq229anzada51", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [ + { + "giver_coin_reward": 0, + "subreddit_id": None, + "is_new": False, + "days_of_drip_extension": 0, + "coin_price": 75, + "id": "award_9663243a-e77f-44cf-abc6-850ead2cd18d", + "penny_donate": 0, + "award_sub_type": "PREMIUM", + "coin_reward": 0, + "icon_url": "https://www.redditstatic.com/gold/awards/icon/SnooClappingPremium_512.png", + "days_of_premium": 0, + "resized_icons": [ + { + "url": "https://www.redditstatic.com/gold/awards/icon/SnooClappingPremium_16.png", + "width": 16, + "height": 16, + }, + { + "url": "https://www.redditstatic.com/gold/awards/icon/SnooClappingPremium_32.png", + "width": 32, + "height": 32, + }, + { + "url": "https://www.redditstatic.com/gold/awards/icon/SnooClappingPremium_48.png", + "width": 48, + "height": 48, + }, + { + "url": "https://www.redditstatic.com/gold/awards/icon/SnooClappingPremium_64.png", + "width": 64, + "height": 64, + }, + { + "url": "https://www.redditstatic.com/gold/awards/icon/SnooClappingPremium_128.png", + "width": 128, + "height": 128, + }, + ], + "icon_width": 512, + "static_icon_width": 512, + "start_date": None, + "is_enabled": True, + "description": "For an especially amazing showing.", + "end_date": None, + "subreddit_coin_reward": 0, + "count": 1, + "static_icon_height": 512, + "name": "Bravo Grande!", + "resized_static_icons": [ + { + "url": "https://preview.redd.it/award_images/t5_q0gj4/59e02tmkl4451_BravoGrande-Static.png?width=16&height=16&auto=webp&s=3459bdf1d1777821a831c5bf9834f4365263fcff", + "width": 16, + "height": 16, + }, + { + "url": "https://preview.redd.it/award_images/t5_q0gj4/59e02tmkl4451_BravoGrande-Static.png?width=32&height=32&auto=webp&s=9181d68065ccfccf2b1074e499cd7c1103aa2ce8", + "width": 32, + "height": 32, + }, + { + "url": "https://preview.redd.it/award_images/t5_q0gj4/59e02tmkl4451_BravoGrande-Static.png?width=48&height=48&auto=webp&s=339b368d395219120abc50d54fb3e2cdcad8ca4f", + "width": 48, + "height": 48, + }, + { + "url": "https://preview.redd.it/award_images/t5_q0gj4/59e02tmkl4451_BravoGrande-Static.png?width=64&height=64&auto=webp&s=de4ebbe92f9019de05aaa77f88810d44adbe1e50", + "width": 64, + "height": 64, + }, + { + "url": "https://preview.redd.it/award_images/t5_q0gj4/59e02tmkl4451_BravoGrande-Static.png?width=128&height=128&auto=webp&s=ba6c1add5204ea43e5af010bd9622392a42140e3", + "width": 128, + "height": 128, + }, + ], + "icon_format": "APNG", + "icon_height": 512, + "penny_price": 0, + "award_type": "global", + "static_icon_url": "https://i.redd.it/award_images/t5_q0gj4/59e02tmkl4451_BravoGrande-Static.png", + } + ], + "awarders": [], + "media_only": False, + "link_flair_template_id": "d7dfae22-4113-11ea-b9fe-0e741fe75651", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh0y", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hpps6f", + "is_robot_indexable": True, + "report_reasons": None, + "author": "Hobo-TheGodOfPoverty", + "discussion_type": None, + "num_comments": 25, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/Python/comments/hpps6f/i_made_a_filename_simplifier_which_removes/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://v.redd.it/jq229anzada51", + "subreddit_subscribers": 616297, + "created_utc": 1594535187, + "num_crossposts": 0, + "media": { + "reddit_video": { + "fallback_url": "https://v.redd.it/jq229anzada51/DASH_1080.mp4?source=fallback", + "height": 1080, + "width": 1920, + "scrubber_media_url": "https://v.redd.it/jq229anzada51/DASH_96.mp4", + "dash_url": "https://v.redd.it/jq229anzada51/DASHPlaylist.mpd?a=1597142191%2CZDU4Y2FmYzI2NjMzZTMxNzJkOThiMzJmYzBlOTMyMmEwNTg3MTFhMmU0OWZjZDljZGQ4MjAwMTgxMGVhYzU1OQ%3D%3D&v=1&f=sd", + "duration": 27, + "hls_url": "https://v.redd.it/jq229anzada51/HLSPlaylist.m3u8?a=1597142191%2CYmY1Y2Q5ZjQ0ZWVmODAxODQ3MGU3YzA1YzIxOTEzODFlNWQyMjE4MzAyYzNiMDM5NTI0N2M5OTRmY2YwN2NlOA%3D%3D&v=1&f=sd", + "is_gif": False, + "transcoding_status": "completed", + } + }, + "is_video": True, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "Python", + "selftext": "", + "author_fullname": "t2_1kjpn251", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Concept Art: what might python look like in Japanese, without any English characters?", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/Python", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "discussion", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hp7uqe", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.94, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 1697, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Discussion", + "can_mod_post": False, + "score": 1697, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [ + { + "approved_at_utc": None, + "subreddit": "ProgrammingLanguages", + "selftext": "", + "author_fullname": "t2_f4rdtgk", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Concept Art: what might python look like in Japanese, without any English characters?", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/ProgrammingLanguages", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_g9iu8x", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.96, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 440, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Discussion", + "can_mod_post": False, + "score": 440, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1588088407, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/ulc23n21jiv41.png", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "93811e06-0da7-11e8-a9a2-0e1129ea8e52", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qi8m", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "g9iu8x", + "is_robot_indexable": True, + "report_reasons": None, + "author": "MartialArtTetherball", + "discussion_type": None, + "num_comments": 65, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/ProgrammingLanguages/comments/g9iu8x/concept_art_what_might_python_look_like_in/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/ulc23n21jiv41.png", + "subreddit_subscribers": 43859, + "created_utc": 1588059607, + "num_crossposts": 2, + "media": None, + "is_video": False, + } + ], + "created": 1594492194, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/ulc23n21jiv41.png", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "0df42996-1c5e-11ea-b1a0-0e44e1c5b731", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh0y", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hp7uqe", + "is_robot_indexable": True, + "report_reasons": None, + "author": "SubstantialRange", + "discussion_type": None, + "num_comments": 182, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_g9iu8x", + "author_flair_text_color": None, + "permalink": "/r/Python/comments/hp7uqe/concept_art_what_might_python_look_like_in/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/ulc23n21jiv41.png", + "subreddit_subscribers": 616297, + "created_utc": 1594463394, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + ], + "after": "t3_hozdzo", + "before": None, + }, +} + +empty_mock = { + "kind": "Listing", + "data": { + "modhash": "y4he8gfzh9f892e2bf3094bc06daba2e02288e617fecf555b5", + "dist": 27, + "children": [], + "after": "t3_hozdzo", + "before": None, + }, +} diff --git a/src/newsreader/news/collection/tests/reddit/collector/tests.py b/src/newsreader/news/collection/tests/reddit/collector/tests.py new file mode 100644 index 0000000..1fd18b0 --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/collector/tests.py @@ -0,0 +1,204 @@ +from datetime import datetime +from unittest.mock import patch +from uuid import uuid4 + +from django.test import TestCase +from django.utils import timezone + +import pytz + +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamForbiddenException, + StreamNotFoundException, + StreamTimeOutException, +) +from newsreader.news.collection.reddit import RedditCollector +from newsreader.news.collection.tests.factories import SubredditFactory +from newsreader.news.collection.tests.reddit.collector.mocks import ( + empty_mock, + simple_mock_1, + simple_mock_2, +) +from newsreader.news.core.models import Post + + +class RedditCollectorTestCase(TestCase): + def setUp(self): + self.maxDiff = None + + self.patched_get = patch("newsreader.news.collection.reddit.fetch") + self.mocked_fetch = self.patched_get.start() + + self.patched_parse = patch( + "newsreader.news.collection.reddit.RedditStream.parse" + ) + self.mocked_parse = self.patched_parse.start() + + def tearDown(self): + patch.stopall() + + def test_simple_batch(self): + self.mocked_parse.side_effect = (simple_mock_1, simple_mock_2) + + rules = ( + (subreddit,) + for subreddit in SubredditFactory.create_batch( + user__reddit_access_token=str(uuid4()), + user__reddit_refresh_token=str(uuid4()), + enabled=True, + size=2, + ) + ) + + collector = RedditCollector() + collector.collect(rules=rules) + + self.assertCountEqual( + Post.objects.values_list("remote_identifier", flat=True), + ( + "hm6byg", + "hpkhgj", + "hph00n", + "hp9mlw", + "hpjn8x", + "gdfaip", + "hmd2ez", + "hpr28u", + "hpps6f", + "hp7uqe", + ), + ) + + for subreddit in rules: + with self.subTest(subreddit=subreddit): + self.assertEquals(subreddit.succeeded, True) + self.assertEquals(subreddit.last_suceeded, timezone.now()) + self.assertEquals(subreddit.error, None) + + post = Post.objects.get( + remote_identifier="hph00n", rule__type=RuleTypeChoices.subreddit + ) + + self.assertEquals( + post.publication_date, pytz.utc.localize(datetime(2020, 7, 11, 22, 23, 24)) + ) + + self.assertEquals(post.author, "HannahB888") + self.assertEquals( + post.title, "Drake Interplanetary Smartkey thing that I made!" + ) + self.assertEquals( + post.url, + "https://www.reddit.com/r/starcitizen/comments/hph00n/drake_interplanetary_smartkey_thing_that_i_made/", + ) + + post = Post.objects.get( + remote_identifier="hpr28u", rule__type=RuleTypeChoices.subreddit + ) + + self.assertEquals( + post.publication_date, pytz.utc.localize(datetime(2020, 7, 12, 10, 29, 10)) + ) + + self.assertEquals(post.author, "Sebaron") + self.assertEquals( + post.title, + "I am a medical student, and I recently programmed an open-source eye-tracker for brain research", + ) + self.assertEquals( + post.url, + "https://www.reddit.com/r/Python/comments/hpr28u/i_am_a_medical_student_and_i_recently_programmed/", + ) + + def test_empty_batch(self): + self.mocked_parse.side_effect = (empty_mock, empty_mock) + + rules = ( + (subreddit,) + for subreddit in SubredditFactory.create_batch( + user__reddit_access_token=str(uuid4()), + user__reddit_refresh_token=str(uuid4()), + enabled=True, + size=2, + ) + ) + + collector = RedditCollector() + collector.collect(rules=rules) + + self.assertEquals(Post.objects.count(), 0) + + for subreddit in rules: + with self.subTest(subreddit=subreddit): + self.assertEquals(subreddit.succeeded, True) + self.assertEquals(subreddit.last_suceeded, timezone.now()) + self.assertEquals(subreddit.error, None) + + def test_not_found(self): + self.mocked_fetch.side_effect = StreamNotFoundException + + rule = SubredditFactory( + user__reddit_access_token=str(uuid4()), + user__reddit_refresh_token=str(uuid4()), + enabled=True, + ) + + collector = RedditCollector() + collector.collect(rules=((rule,),)) + + self.assertEquals(Post.objects.count(), 0) + self.assertEquals(rule.succeeded, False) + self.assertEquals(rule.error, "Stream not found") + + @patch("newsreader.news.collection.reddit.RedditTokenTask") + def test_denied(self, mocked_task): + self.mocked_fetch.side_effect = StreamDeniedException + + rule = SubredditFactory( + user__reddit_access_token=str(uuid4()), + user__reddit_refresh_token=str(uuid4()), + enabled=True, + ) + + collector = RedditCollector() + collector.collect(rules=((rule,),)) + + self.assertEquals(Post.objects.count(), 0) + self.assertEquals(rule.succeeded, False) + self.assertEquals(rule.error, "Stream does not have sufficient permissions") + + mocked_task.delay.assert_called_once_with(rule.user.pk) + + def test_forbidden(self): + self.mocked_fetch.side_effect = StreamForbiddenException + + rule = SubredditFactory( + user__reddit_access_token=str(uuid4()), + user__reddit_refresh_token=str(uuid4()), + enabled=True, + ) + + collector = RedditCollector() + collector.collect(rules=((rule,),)) + + self.assertEquals(Post.objects.count(), 0) + self.assertEquals(rule.succeeded, False) + self.assertEquals(rule.error, "Stream forbidden") + + def test_timed_out(self): + self.mocked_fetch.side_effect = StreamTimeOutException + + rule = SubredditFactory( + user__reddit_access_token=str(uuid4()), + user__reddit_refresh_token=str(uuid4()), + enabled=True, + ) + + collector = RedditCollector() + collector.collect(rules=((rule,),)) + + self.assertEquals(Post.objects.count(), 0) + self.assertEquals(rule.succeeded, False) + self.assertEquals(rule.error, "Stream timed out") diff --git a/src/newsreader/news/collection/tests/reddit/stream/__init__.py b/src/newsreader/news/collection/tests/reddit/stream/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/reddit/stream/mocks.py b/src/newsreader/news/collection/tests/reddit/stream/mocks.py new file mode 100644 index 0000000..148b31a --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/stream/mocks.py @@ -0,0 +1,3289 @@ +simple_mock = { + "kind": "Listing", + "data": { + "modhash": "sgq4fdizx94db5c05b57f9957a4b8b2d5e24b712f5a507cffd", + "dist": 27, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "Welcome to r/linux rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.\n\nLet us know what's annoying you, whats making you happy, or something that you want to get out to r/linux but didn't make the cut into a full post of it's own.\n\nFor those looking for certifications please use this megathread to ask about how to get certified whether it's for the business world or for your own satisfaction. Be sure to check out r/linuxadmin for more discussion in the SysAdmin world!\n\n_Please keep questions in r/linuxquestions, r/linux4noobs, or the Wednesday automod thread._", + "author_fullname": "t2_6l4z3", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hm0qct", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.65, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 6, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 6, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594037482.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>Welcome to <a href="/r/linux">r/linux</a> rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.</p>\n\n<p>Let us know what&#39;s annoying you, whats making you happy, or something that you want to get out to <a href="/r/linux">r/linux</a> but didn&#39;t make the cut into a full post of it&#39;s own.</p>\n\n<p>For those looking for certifications please use this megathread to ask about how to get certified whether it&#39;s for the business world or for your own satisfaction. Be sure to check out <a href="/r/linuxadmin">r/linuxadmin</a> for more discussion in the SysAdmin world!</p>\n\n<p><em>Please keep questions in <a href="/r/linuxquestions">r/linuxquestions</a>, <a href="/r/linux4noobs">r/linux4noobs</a>, or the Wednesday automod thread.</em></p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": "moderator", + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hm0qct", + "is_robot_indexable": True, + "report_reasons": None, + "author": "AutoModerator", + "discussion_type": None, + "num_comments": 8, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", + "subreddit_subscribers": 543995, + "created_utc": 1594008682.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "Welcome to r/linux! If you're new to Linux or trying to get started this thread is for you. Get help here or as always, check out r/linuxquestions or r/linux4noobs\n\nThis megathread is for all your question needs. As we don't allow questions on r/linux outside of this megathread, please consider using r/linuxquestions or r/linux4noobs for the best solution to your problem.\n\nAsk your hardware requests here too or try r/linuxhardware!", + "author_fullname": "t2_6l4z3", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Weekly Questions and Hardware Thread - July 08, 2020", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hna75r", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.5, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 0, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 0, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594210138.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>Welcome to <a href="/r/linux">r/linux</a>! If you&#39;re new to Linux or trying to get started this thread is for you. Get help here or as always, check out <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a></p>\n\n<p>This megathread is for all your question needs. As we don&#39;t allow questions on <a href="/r/linux">r/linux</a> outside of this megathread, please consider using <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a> for the best solution to your problem.</p>\n\n<p>Ask your hardware requests here too or try <a href="/r/linuxhardware">r/linuxhardware</a>!</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": "new", + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": "moderator", + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hna75r", + "is_robot_indexable": True, + "report_reasons": None, + "author": "AutoModerator", + "discussion_type": None, + "num_comments": 2, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", + "subreddit_subscribers": 543995, + "created_utc": 1594181338.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_gr7k5", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Here's a feature Linux could borrow from BSD: in-kernel debugger with built-in hangman game", + "link_flair_richtext": [{"e": "text", "t": "Fluff"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hngs71", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.9, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 135, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Fluff", + "can_mod_post": False, + "score": 135, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594242629.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/wmc8tp2ium951.jpg", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "af8918be-6777-11e7-8273-0e925d908786", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#9a2bff", + "id": "hngs71", + "is_robot_indexable": True, + "report_reasons": None, + "author": "the_humeister", + "discussion_type": None, + "num_comments": 20, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hngs71/heres_a_feature_linux_could_borrow_from_bsd/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/wmc8tp2ium951.jpg", + "subreddit_subscribers": 543995, + "created_utc": 1594213829.0, + "num_crossposts": 1, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_k9f35", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "KeePassXC 2.6.0 released", + "link_flair_richtext": [{"e": "text", "t": "Software Release"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hngsj8", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.97, + "author_flair_background_color": "transparent", + "subreddit_type": "public", + "ups": 126, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": "fa602e36-cdf6-11e8-93c9-0e41ac35f4cc", + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Software Release", + "can_mod_post": False, + "score": 126, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [ + { + "a": ":ubuntu:", + "e": "emoji", + "u": "https://emoji.redditmedia.com/uwmddx7qqpr11_t5_2qh1a/ubuntu", + } + ], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594242666.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "keepassxc.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://keepassxc.org/blog/2020-07-07-2.6.0-released/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "904ea3e4-6748-11e7-b925-0ef3dfbb807a", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": ":ubuntu:", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#349e48", + "id": "hngsj8", + "is_robot_indexable": True, + "report_reasons": None, + "author": "nixcraft", + "discussion_type": None, + "num_comments": 42, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/linux/comments/hngsj8/keepassxc_260_released/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://keepassxc.org/blog/2020-07-07-2.6.0-released/", + "subreddit_subscribers": 543995, + "created_utc": 1594213866.0, + "num_crossposts": 1, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [{"e": "text", "t": "Popular Application"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd7cy", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.95, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 223, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Popular Application", + "can_mod_post": False, + "score": 223, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [ + { + "approved_at_utc": None, + "subreddit": "libreoffice", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/libreoffice", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd6yo", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.94, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 28, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "News", + "can_mod_post": False, + "score": 28, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594224961.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "dc82ac98-bafb-11e4-9f88-22000b310327", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2s4nt", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnd6yo", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZero", + "discussion_type": None, + "num_comments": 38, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/libreoffice/comments/hnd6yo/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 4669, + "created_utc": 1594196161.0, + "num_crossposts": 2, + "media": None, + "is_video": False, + } + ], + "created": 1594225018.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "7127ec98-5859-11e8-9488-0e8717893ec8", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#0aa18f", + "id": "hnd7cy", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZero", + "discussion_type": None, + "num_comments": 109, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_hnd6yo", + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnd7cy/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 543995, + "created_utc": 1594196218.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_6cxnzaq2", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Gentoo Now on Android Platform !!!", + "link_flair_richtext": [{"e": "text", "t": "Mobile Linux"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnemei", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.87, + "author_flair_background_color": "transparent", + "subreddit_type": "public", + "ups": 78, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": "a54a7460-cdf6-11e8-b31c-0e89679a2148", + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Mobile Linux", + "can_mod_post": False, + "score": 78, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [ + { + "a": ":arch:", + "e": "emoji", + "u": "https://emoji.redditmedia.com/tip79drnqpr11_t5_2qh1a/arch", + } + ], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594232773.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "gentoo.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://www.gentoo.org/news/2020/07/07/gentoo-android.html", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "84162644-5859-11e8-b9ed-0efda312d094", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": ":arch:", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#d78216", + "id": "hnemei", + "is_robot_indexable": True, + "report_reasons": None, + "author": "draplon", + "discussion_type": None, + "num_comments": 21, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/linux/comments/hnemei/gentoo_now_on_android_platform/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.gentoo.org/news/2020/07/07/gentoo-android.html", + "subreddit_subscribers": 543995, + "created_utc": 1594203973.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_f9vxe", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Google is teaming up with Ubuntu to bring Flutter apps to Linux", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hniojf", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.77, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 31, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 31, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594249580.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "androidpolice.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://www.androidpolice.com/2020/07/08/google-is-teaming-up-with-ubuntu-to-bring-flutter-apps-to-linux/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hniojf", + "is_robot_indexable": True, + "report_reasons": None, + "author": "bilal4hmed", + "discussion_type": None, + "num_comments": 24, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hniojf/google_is_teaming_up_with_ubuntu_to_bring_flutter/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.androidpolice.com/2020/07/08/google-is-teaming-up-with-ubuntu-to-bring-flutter-apps-to-linux/", + "subreddit_subscribers": 543995, + "created_utc": 1594220780.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_k9f35", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Ariane RISC-V CPU \u2013 An open source CPU capable of booting Linux", + "link_flair_richtext": [{"e": "text", "t": "Hardware"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hngr1j", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.89, + "author_flair_background_color": "transparent", + "subreddit_type": "public", + "ups": 49, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": "fa602e36-cdf6-11e8-93c9-0e41ac35f4cc", + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Hardware", + "can_mod_post": False, + "score": 49, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [ + { + "a": ":ubuntu:", + "e": "emoji", + "u": "https://emoji.redditmedia.com/uwmddx7qqpr11_t5_2qh1a/ubuntu", + } + ], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594242511.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "github.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://github.com/openhwgroup/cva6", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "3d48793a-c823-11e8-9a58-0ee3c97eb952", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": ":ubuntu:", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#cc5289", + "id": "hngr1j", + "is_robot_indexable": True, + "report_reasons": None, + "author": "nixcraft", + "discussion_type": None, + "num_comments": 15, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/linux/comments/hngr1j/ariane_riscv_cpu_an_open_source_cpu_capable_of/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://github.com/openhwgroup/cva6", + "subreddit_subscribers": 543995, + "created_utc": 1594213711.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_6kt9ukjs", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Canonical enables Linux desktop app support with Flutter", + "link_flair_richtext": [{"e": "text", "t": "Software Release"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnj1ap", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.79, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 24, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Software Release", + "can_mod_post": False, + "score": 24, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594250752.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "ubuntu.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://ubuntu.com/blog/canonical-enables-linux-desktop-app-support-with-flutter", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "904ea3e4-6748-11e7-b925-0ef3dfbb807a", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#349e48", + "id": "hnj1ap", + "is_robot_indexable": True, + "report_reasons": None, + "author": "hmblhstl", + "discussion_type": None, + "num_comments": 28, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnj1ap/canonical_enables_linux_desktop_app_support_with/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://ubuntu.com/blog/canonical-enables-linux-desktop-app-support-with-flutter", + "subreddit_subscribers": 543995, + "created_utc": 1594221952.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_3vf8x", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Sandboxing in Linux with zero lines of code", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnfzbm", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.83, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 30, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 30, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594239285.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.cloudflare.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.cloudflare.com/sandboxing-in-linux-with-zero-lines-of-code/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnfzbm", + "is_robot_indexable": True, + "report_reasons": None, + "author": "pimterry", + "discussion_type": None, + "num_comments": 0, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnfzbm/sandboxing_in_linux_with_zero_lines_of_code/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.cloudflare.com/sandboxing-in-linux-with-zero-lines-of-code/", + "subreddit_subscribers": 543995, + "created_utc": 1594210485.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_318in", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "SUSE Enters Into Definitive Agreement to Acquire Rancher Labs", + "link_flair_richtext": [ + {"e": "text", "t": "Open Source Organization"} + ], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnh5ux", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.84, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 26, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Open Source Organization", + "can_mod_post": False, + "score": 26, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594244123.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "rancher.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://rancher.com/blog/2020/suse-to-acquire-rancher/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "8a1dd4b0-5859-11e8-a2c7-0e5ebdbe24d6", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#800000", + "id": "hnh5ux", + "is_robot_indexable": True, + "report_reasons": None, + "author": "hjames9", + "discussion_type": None, + "num_comments": 5, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnh5ux/suse_enters_into_definitive_agreement_to_acquire/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://rancher.com/blog/2020/suse-to-acquire-rancher/", + "subreddit_subscribers": 543995, + "created_utc": 1594215323.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_j1a5", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Linux Mint drops Ubuntu Snap packages [LWN.net]", + "link_flair_richtext": [{"e": "text", "t": "Distro News"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": True, + "name": "t3_hnlt4l", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.8, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 9, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Distro News", + "can_mod_post": False, + "score": 9, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594259641.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "lwn.net", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://lwn.net/SubscriberLink/825005/6440c82feb745bbe/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "6888e772-5859-11e8-82ff-0e816ab71260", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#0dd3bb", + "id": "hnlt4l", + "is_robot_indexable": True, + "report_reasons": None, + "author": "tapo", + "discussion_type": None, + "num_comments": 3, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnlt4l/linux_mint_drops_ubuntu_snap_packages_lwnnet/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://lwn.net/SubscriberLink/825005/6440c82feb745bbe/", + "subreddit_subscribers": 543995, + "created_utc": 1594230841.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_4i3yk", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Announcing Flutter Linux Alpha with Canonical", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hniq04", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.6, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 6, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 6, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594249712.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "medium.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://medium.com/flutter/announcing-flutter-linux-alpha-with-canonical-19eb824590a9", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hniq04", + "is_robot_indexable": True, + "report_reasons": None, + "author": "popeydc", + "discussion_type": None, + "num_comments": 3, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hniq04/announcing_flutter_linux_alpha_with_canonical/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://medium.com/flutter/announcing-flutter-linux-alpha-with-canonical-19eb824590a9", + "subreddit_subscribers": 543995, + "created_utc": 1594220912.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_611c0ard", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "New anti-encryption bill worse than EARN IT, would force a backdoor into any US device/software. Act now to stop both.", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hmp66i", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.98, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 3340, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 3340, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594131589.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "tutanota.com", + "allow_live_comments": True, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://tutanota.com/blog/posts/lawful-access-encrypted-data-act-backdoor", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hmp66i", + "is_robot_indexable": True, + "report_reasons": None, + "author": "fossfans", + "discussion_type": None, + "num_comments": 380, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hmp66i/new_antiencryption_bill_worse_than_earn_it_would/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://tutanota.com/blog/posts/lawful-access-encrypted-data-act-backdoor", + "subreddit_subscribers": 543995, + "created_utc": 1594102789.0, + "num_crossposts": 2, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "We have had Freesync \"support\" for quite some time now, but it is extremely restrictive and very picky to get it working. Just the requirements to have Freesync working is no-go for many:\n\n\\-> Single monitor only;\n\n\\-> No video playback or turning it on while on desktop;\n\n\\-> Should only be turned on only while the game/software in question is in fullscreen;\n\n\\-> X11, no Wayland;\n\n\\-> Only tested/working distro is Ubuntu 16.04.3;\n\n\\-> Need of setting it up through some quite cryptic commands;\n\n\\-> Doesn't work after hotplug or system restart;\n\n\\-> No Freesync over HDMI (which isn't a massive problem, but a nice option to have);\n\n\\-> Apparently only OpenGL, no Vulkan (Steam Play/Proton, which is the main purpose for Freesync at the moment, doesn't work);\n\n&#x200B;\n\nI am not really complaining, because I do know that Freesync is hard to get working on Linux, but we have had so many advancements on the gaming side of Linux, and we are still stuck with all of these restrictions to use Freesync, which is quite a useful functionality for almost every gamer. If Mozilla got video decoding working well on Wayland, I hope (Idk anything about this, just hoping) that it could also be easy to implement Freesync on Wayland too.\n\nWe just haven't had that many improvements on this side of the Linux gaming world, and I'd like to know if it is lack of support/interest by AMD, or if it actually is extremely hard to implement it on Linux. Freesync would also be very useful for those who are running monitors over 60Hz, so that those 60FPS videos don't look as weird as they do while playing back on higher refresh rate monitors. It is just a nice thing for everybody, really!", + "author_fullname": "t2_1afv9v8g", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Any evolution on the Freesync situation on Linux?", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hn7agp", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.85, + "author_flair_background_color": "transparent", + "subreddit_type": "public", + "ups": 83, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": "fa602e36-cdf6-11e8-93c9-0e41ac35f4cc", + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 83, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [ + { + "a": ":ubuntu:", + "e": "emoji", + "u": "https://emoji.redditmedia.com/uwmddx7qqpr11_t5_2qh1a/ubuntu", + } + ], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594199056.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>We have had Freesync &quot;support&quot; for quite some time now, but it is extremely restrictive and very picky to get it working. Just the requirements to have Freesync working is no-go for many:</p>\n\n<p>-&gt; Single monitor only;</p>\n\n<p>-&gt; No video playback or turning it on while on desktop;</p>\n\n<p>-&gt; Should only be turned on only while the game/software in question is in fullscreen;</p>\n\n<p>-&gt; X11, no Wayland;</p>\n\n<p>-&gt; Only tested/working distro is Ubuntu 16.04.3;</p>\n\n<p>-&gt; Need of setting it up through some quite cryptic commands;</p>\n\n<p>-&gt; Doesn&#39;t work after hotplug or system restart;</p>\n\n<p>-&gt; No Freesync over HDMI (which isn&#39;t a massive problem, but a nice option to have);</p>\n\n<p>-&gt; Apparently only OpenGL, no Vulkan (Steam Play/Proton, which is the main purpose for Freesync at the moment, doesn&#39;t work);</p>\n\n<p>&#x200B;</p>\n\n<p>I am not really complaining, because I do know that Freesync is hard to get working on Linux, but we have had so many advancements on the gaming side of Linux, and we are still stuck with all of these restrictions to use Freesync, which is quite a useful functionality for almost every gamer. If Mozilla got video decoding working well on Wayland, I hope (Idk anything about this, just hoping) that it could also be easy to implement Freesync on Wayland too.</p>\n\n<p>We just haven&#39;t had that many improvements on this side of the Linux gaming world, and I&#39;d like to know if it is lack of support/interest by AMD, or if it actually is extremely hard to implement it on Linux. Freesync would also be very useful for those who are running monitors over 60Hz, so that those 60FPS videos don&#39;t look as weird as they do while playing back on higher refresh rate monitors. It is just a nice thing for everybody, really!</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": ":ubuntu:", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hn7agp", + "is_robot_indexable": True, + "report_reasons": None, + "author": "mreich98", + "discussion_type": None, + "num_comments": 36, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/linux/comments/hn7agp/any_evolution_on_the_freesync_situation_on_linux/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.reddit.com/r/linux/comments/hn7agp/any_evolution_on_the_freesync_situation_on_linux/", + "subreddit_subscribers": 543995, + "created_utc": 1594170256.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_7ccf", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Running Rosetta@home on a Raspberry Pi with Fedora IoT", + "link_flair_richtext": [{"e": "text", "t": "Popular Application"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnfw0h", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.73, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 8, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Popular Application", + "can_mod_post": False, + "score": 8, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594238884.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "fedoramagazine.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://fedoramagazine.org/running-rosettahome-on-a-raspberry-pi-with-fedora-iot/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "7127ec98-5859-11e8-9488-0e8717893ec8", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#0aa18f", + "id": "hnfw0h", + "is_robot_indexable": True, + "report_reasons": None, + "author": "speckz", + "discussion_type": None, + "num_comments": 1, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnfw0h/running_rosettahome_on_a_raspberry_pi_with_fedora/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://fedoramagazine.org/running-rosettahome-on-a-raspberry-pi-with-fedora-iot/", + "subreddit_subscribers": 543995, + "created_utc": 1594210084.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_sx11s", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Getting Things GNOME 0.4 released! First release in almost 7 years (Flatpak available).", + "link_flair_richtext": [{"e": "text", "t": "Software Release"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hn5wh6", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.79, + "author_flair_background_color": "transparent", + "subreddit_type": "public", + "ups": 58, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": "2194c338-ce1d-11e8-8ed7-0e20bb1bbc52", + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Software Release", + "can_mod_post": False, + "score": 58, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [ + { + "a": ":nix:", + "e": "emoji", + "u": "https://emoji.redditmedia.com/ww1ubcjpqpr11_t5_2qh1a/nix", + } + ], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594193982.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "flathub.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://flathub.org/apps/details/org.gnome.GTG", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "904ea3e4-6748-11e7-b925-0ef3dfbb807a", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": ":nix:", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#349e48", + "id": "hn5wh6", + "is_robot_indexable": True, + "report_reasons": None, + "author": "Kanarme", + "discussion_type": None, + "num_comments": 22, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/linux/comments/hn5wh6/getting_things_gnome_04_released_first_release_in/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://flathub.org/apps/details/org.gnome.GTG", + "subreddit_subscribers": 543995, + "created_utc": 1594165182.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_636xx258", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "mpv is not anymore supporting gnome. and the owner reverted the commit again shortly after and then again made a new one, to add the changes", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": True, + "name": "t3_hnnt0v", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 1.0, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 1, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 1, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [ + { + "approved_at_utc": None, + "subreddit": "gnome", + "selftext": "", + "author_fullname": "t2_33wgs4m3", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "mpv is not anymore supporting gnome. and the owner reverted the commit again shortly after and then again made a new one, to add the changes", + "link_flair_richtext": [{"e": "text", "t": "News"}], + "subreddit_name_prefixed": "r/gnome", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hn1s3r", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.81, + "author_flair_background_color": "transparent", + "subreddit_type": "public", + "ups": 23, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": "1515012e-bed8-11ea-92a7-0eb4e155a177", + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "News", + "can_mod_post": False, + "score": 23, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": "gnome-user", + "author_flair_richtext": [{"e": "text", "t": "GNOMie"}], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594180508.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "github.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": "confidence", + "banned_at_utc": None, + "url_overridden_by_dest": "https://github.com/mpv-player/mpv/commit/cdaa496314f90412963f2b3211e18df72910066d#commitcomment-40434556", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "7dbe0c80-f9df-11e8-b35e-0e2ae22a2534", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": "GNOMie", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qjhn", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#692c52", + "id": "hn1s3r", + "is_robot_indexable": True, + "report_reasons": None, + "author": "idiot10000000", + "discussion_type": None, + "num_comments": 53, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/gnome/comments/hn1s3r/mpv_is_not_anymore_supporting_gnome_and_the_owner/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://github.com/mpv-player/mpv/commit/cdaa496314f90412963f2b3211e18df72910066d#commitcomment-40434556", + "subreddit_subscribers": 41350, + "created_utc": 1594151708.0, + "num_crossposts": 1, + "media": None, + "is_video": False, + } + ], + "created": 1594265700.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "github.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://github.com/mpv-player/mpv/commit/cdaa496314f90412963f2b3211e18df72910066d#commitcomment-40434556", + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnnt0v", + "is_robot_indexable": True, + "report_reasons": None, + "author": "RetartedTortoise", + "discussion_type": None, + "num_comments": 0, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_hn1s3r", + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnnt0v/mpv_is_not_anymore_supporting_gnome_and_the_owner/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://github.com/mpv-player/mpv/commit/cdaa496314f90412963f2b3211e18df72910066d#commitcomment-40434556", + "subreddit_subscribers": 543995, + "created_utc": 1594236900.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_21omsw7y", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Google and Canonical bring Linux apps support to Flutter - 9to5Google", + "link_flair_richtext": [{"e": "text", "t": "Development"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnj42j", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.59, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 3, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Development", + "can_mod_post": False, + "score": 3, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594251002.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "9to5google.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://9to5google.com/2020/07/08/google-canonical-partnership-linux-flutter-apps/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "3cb511e2-7914-11ea-bb33-0ee30ee9d22b", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#f0db8a", + "id": "hnj42j", + "is_robot_indexable": True, + "report_reasons": None, + "author": "satvikpendem", + "discussion_type": None, + "num_comments": 1, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnj42j/google_and_canonical_bring_linux_apps_support_to/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://9to5google.com/2020/07/08/google-canonical-partnership-linux-flutter-apps/", + "subreddit_subscribers": 543995, + "created_utc": 1594222202.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": " As far as I understand it, the current options on the Intel Iris/NVIDIA side are:\n\n* Intel or NVIDIA cards only\n\n* Optimus for switching between Intel and Intel+NVIDIA (requires reboot)\n\n* Bumblebee for on-the-fly switching with a performance hit\n\n* nvidia-xrun, which does everything bumblebee can do but requires a second X server\n\n* Prime Rener Offload, a proprietary NVIDIA thing, for switching between Intel and Intel+NVIDIA, which I don't completely understand\n\nDo I have this right? And how do things look on the Amd Vega/Radeon configuration?", + "author_fullname": "t2_tcnt4", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "[Discussion] What's the current status on laptop switchable graphics?", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": True, + "name": "t3_hnmiik", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.67, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 1, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 1, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594261813.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>As far as I understand it, the current options on the Intel Iris/NVIDIA side are:</p>\n\n<ul>\n<li><p>Intel or NVIDIA cards only</p></li>\n<li><p>Optimus for switching between Intel and Intel+NVIDIA (requires reboot)</p></li>\n<li><p>Bumblebee for on-the-fly switching with a performance hit</p></li>\n<li><p>nvidia-xrun, which does everything bumblebee can do but requires a second X server</p></li>\n<li><p>Prime Rener Offload, a proprietary NVIDIA thing, for switching between Intel and Intel+NVIDIA, which I don&#39;t completely understand</p></li>\n</ul>\n\n<p>Do I have this right? And how do things look on the Amd Vega/Radeon configuration?</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnmiik", + "is_robot_indexable": True, + "report_reasons": None, + "author": "KoolDude214", + "discussion_type": None, + "num_comments": 4, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnmiik/discussion_whats_the_current_status_on_laptop/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.reddit.com/r/linux/comments/hnmiik/discussion_whats_the_current_status_on_laptop/", + "subreddit_subscribers": 543995, + "created_utc": 1594233013.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "Hello all!\n\nI've created this simple web app as a part of learning web development, to help people select a linux distro for themselves.\n\nIt's a really simple web app, as I've created it as part of learning web development.\n\nIt retrieves data from another API that I've defined and this very API's database is used to store all the releated information that only right now I can store.\n\nAnd this web app is used to get information from that API and display it in an organized way.\n\nHave a look and please let me know about your thoughts and suggestions:\n\nLink: [https://linux-distros-encyclopedia.herokuapp.com/](https://linux-distros-encyclopedia.herokuapp.com/)", + "author_fullname": "t2_4c9tcvx3", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Linux Distributions Encyclopedia Web App", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnlh54", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.5, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 0, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 0, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594258586.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>Hello all!</p>\n\n<p>I&#39;ve created this simple web app as a part of learning web development, to help people select a linux distro for themselves.</p>\n\n<p>It&#39;s a really simple web app, as I&#39;ve created it as part of learning web development.</p>\n\n<p>It retrieves data from another API that I&#39;ve defined and this very API&#39;s database is used to store all the releated information that only right now I can store.</p>\n\n<p>And this web app is used to get information from that API and display it in an organized way.</p>\n\n<p>Have a look and please let me know about your thoughts and suggestions:</p>\n\n<p>Link: <a href="https://linux-distros-encyclopedia.herokuapp.com/">https://linux-distros-encyclopedia.herokuapp.com/</a></p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnlh54", + "is_robot_indexable": True, + "report_reasons": None, + "author": "MisterKhJe", + "discussion_type": None, + "num_comments": 2, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnlh54/linux_distributions_encyclopedia_web_app/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.reddit.com/r/linux/comments/hnlh54/linux_distributions_encyclopedia_web_app/", + "subreddit_subscribers": 543995, + "created_utc": 1594229786.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "I would like to turn my old Asus tablet into an ultimate linux-based Ebook reader. It's currently running kali linux due to my netsec background and I can't say that it runs flawlessly. The tablet came with Windows 10 by default. Does anyone have the experience with what distro and pdf reader to use?\n\nIt has to be lightweight due to 1.3Ghz Atom processor and 1Gb of Ram.", + "author_fullname": "t2_y0rlp", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Linux based Ebook reader tablet", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnecim", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.56, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 2, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 2, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594231304.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>I would like to turn my old Asus tablet into an ultimate linux-based Ebook reader. It&#39;s currently running kali linux due to my netsec background and I can&#39;t say that it runs flawlessly. The tablet came with Windows 10 by default. Does anyone have the experience with what distro and pdf reader to use?</p>\n\n<p>It has to be lightweight due to 1.3Ghz Atom processor and 1Gb of Ram.</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnecim", + "is_robot_indexable": True, + "report_reasons": None, + "author": "Kikur", + "discussion_type": None, + "num_comments": 5, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnecim/linux_based_ebook_reader_tablet/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.reddit.com/r/linux/comments/hnecim/linux_based_ebook_reader_tablet/", + "subreddit_subscribers": 543995, + "created_utc": 1594202504.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_300vb", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Backing up my work-provided Windows laptop with Debian, ZFS and SquashFS", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hn2ro8", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.74, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 23, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 23, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594183686.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "thanassis.space", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://www.thanassis.space/backupCOVID.html", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hn2ro8", + "is_robot_indexable": True, + "report_reasons": None, + "author": "ttsiodras", + "discussion_type": None, + "num_comments": 5, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hn2ro8/backing_up_my_workprovided_windows_laptop_with/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.thanassis.space/backupCOVID.html", + "subreddit_subscribers": 543995, + "created_utc": 1594154886.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_2ccbdhht", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Debian influences everywhere", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": True, + "name": "t3_hnndj2", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.36, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 0, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 0, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [ + { + "approved_at_utc": None, + "subreddit": "ramen", + "selftext": "", + "author_fullname": "t2_1e5jztuf", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "My 1st Attempt for Tori Paitan", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/ramen", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": True, + "name": "t3_hnn89u", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 1.0, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 2, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Homemade", + "can_mod_post": False, + "score": 2, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594263979.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/ai9r2wu5mo951.jpg", + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "28b48e48-ce25-11e8-94f2-0e1ed223bf48", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qykd", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#ffd635", + "id": "hnn89u", + "is_robot_indexable": True, + "report_reasons": None, + "author": "cheesychicken80", + "discussion_type": None, + "num_comments": 1, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/ramen/comments/hnn89u/my_1st_attempt_for_tori_paitan/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/ai9r2wu5mo951.jpg", + "subreddit_subscribers": 257000, + "created_utc": 1594235179.0, + "num_crossposts": 1, + "media": None, + "is_video": False, + } + ], + "created": 1594264403.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/ai9r2wu5mo951.jpg", + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnndj2", + "is_robot_indexable": True, + "report_reasons": None, + "author": "dracardOner", + "discussion_type": None, + "num_comments": 0, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_hnn89u", + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnndj2/debian_influences_everywhere/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/ai9r2wu5mo951.jpg", + "subreddit_subscribers": 543995, + "created_utc": 1594235603.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "There is an open issue in Electron-Builder to add option to easily create flatpak repo. This results in many electron apps not officially/easily supporting flatpak, thus solving this would help flatpak adoption and make it easier for users to install their favourite apps. See the issue on github for more info [https://github.com/electron-userland/electron-builder/issues/512](https://github.com/electron-userland/electron-builder/issues/512)\n\nSince there are no technical obstacles that prevent completing this task, I made a small bounty on gitpay [https://gitpay.me/#/task/352](https://gitpay.me/#/task/352) to motivate developers, and if you care about this issue, consider chiming in too, spreading the word or even giving a try at implementing this :)", + "author_fullname": "t2_5hgjidqm", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Crowdsource Flatpak support in Electron-Builder", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hmytic", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.76, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 37, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 37, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594171301.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>There is an open issue in Electron-Builder to add option to easily create flatpak repo. This results in many electron apps not officially/easily supporting flatpak, thus solving this would help flatpak adoption and make it easier for users to install their favourite apps. See the issue on github for more info <a href="https://github.com/electron-userland/electron-builder/issues/512">https://github.com/electron-userland/electron-builder/issues/512</a></p>\n\n<p>Since there are no technical obstacles that prevent completing this task, I made a small bounty on gitpay <a href="https://gitpay.me/#/task/352">https://gitpay.me/#/task/352</a> to motivate developers, and if you care about this issue, consider chiming in too, spreading the word or even giving a try at implementing this :)</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hmytic", + "is_robot_indexable": True, + "report_reasons": None, + "author": "ignapk", + "discussion_type": None, + "num_comments": 23, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hmytic/crowdsource_flatpak_support_in_electronbuilder/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.reddit.com/r/linux/comments/hmytic/crowdsource_flatpak_support_in_electronbuilder/", + "subreddit_subscribers": 543995, + "created_utc": 1594142501.0, + "num_crossposts": 5, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "I was experiencing graphic issues and glitches in some games while using Linux Ubuntu 20.04 LTS with my Ryzen 3 3250u CPU and I wanted to share how I fixed this issue for anyone else with this same problem.\n\nFirst thing you should try is setting 'AMD_DEBUG=nodmacopyimage' as an environmental variable. This only partly fixed the issue for me as most of the in-game textures were still glitchy and messed up. However this method seemed to work for some other people https://gitlab.freedesktop.org/mesa/mesa/-/issues/2814\n\nThe second method I tried was downgrading from Ubuntu 20.04 to Ubuntu 19.10. This fixed my problem instantly and the glitchy in-game textures were no longer an issue.\n\n\nIm still new to Linux and not very tech savvy so I can't provide a detailed explanation of what causes this problem and why these methods seem to fix it however I'm pretty sure its something to do with the AMD graphics drivers. Hopefully this issue will be fixed in the next Ubuntu update\n\nHope this helped ;)", + "author_fullname": "t2_6qntnayu", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Linux Graphical Glitches on Ryzen CPUs", + "link_flair_richtext": [{"e": "text", "t": "Tips and Tricks"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hmxiyt", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.79, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 20, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Tips and Tricks", + "can_mod_post": False, + "score": 20, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594167246.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>I was experiencing graphic issues and glitches in some games while using Linux Ubuntu 20.04 LTS with my Ryzen 3 3250u CPU and I wanted to share how I fixed this issue for anyone else with this same problem.</p>\n\n<p>First thing you should try is setting &#39;AMD_DEBUG=nodmacopyimage&#39; as an environmental variable. This only partly fixed the issue for me as most of the in-game textures were still glitchy and messed up. However this method seemed to work for some other people <a href="https://gitlab.freedesktop.org/mesa/mesa/-/issues/2814">https://gitlab.freedesktop.org/mesa/mesa/-/issues/2814</a></p>\n\n<p>The second method I tried was downgrading from Ubuntu 20.04 to Ubuntu 19.10. This fixed my problem instantly and the glitchy in-game textures were no longer an issue.</p>\n\n<p>Im still new to Linux and not very tech savvy so I can&#39;t provide a detailed explanation of what causes this problem and why these methods seem to fix it however I&#39;m pretty sure its something to do with the AMD graphics drivers. Hopefully this issue will be fixed in the next Ubuntu update</p>\n\n<p>Hope this helped ;)</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "de62f716-76df-11ea-802c-0e7469f68f6b", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#00a6a5", + "id": "hmxiyt", + "is_robot_indexable": True, + "report_reasons": None, + "author": "Inolicious_", + "discussion_type": None, + "num_comments": 9, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hmxiyt/linux_graphical_glitches_on_ryzen_cpus/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.reddit.com/r/linux/comments/hmxiyt/linux_graphical_glitches_on_ryzen_cpus/", + "subreddit_subscribers": 543995, + "created_utc": 1594138446.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "Well, alright, at various points in my life I may have been more pleased, but Windows has been losing my support for years one small nitpick at a time. Just wanted to share the change for whoever cares.\n\n* I liked the look and massive size of the windows less and less \n* As a programmer using bash and zsh on cygwin became more and more annoying\n* Windows keeps randomly changing stuff that I never wanted, like my downloads folder becoming a date-sorted list instead of an actual folder (and switching it back when I changed it!)\n* Adding cortana and the like and making it difficult to disable\n* Windows update.\n* Almost every bit of software I have at this point is also on linux or through a browser!\n\nI switched to Manjaro-Gnome and never looked back.\n\n* It's sleeker/runs faster.\n* Uses less RAM\n* Uses rolling updates\n* I can finally just use a built-in terminal\n* Has an easier to understand file structure, despite its complexity.\n* Is surprisingly easy to use. The only difficult part really was finding the wifi driver, and that was actually because it was mislabeled by the manufacturer.\n* Gnome is definitely nicer to use than Windows 10.\n* Searching for files and programs works well! I really didn't need windows to fail to find a program I had installed and instead offer to search for it online... through Bing on Edge.\n\nI never knew how much bloat Windows had until I switched over. This is so damn nice. I don't know why I didn't consider Linux as a serious alternative until recently. Steam Proton has also come a long, long way, I haven't had issues with a game yet.\n\nAnyways, I just wanted to rant, and I'm probably going to install an Manjaro-xfce on a bunch of old laptops.", + "author_fullname": "t2_8zm4y", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Switched from Windows 10 to Manjaro, never been happier", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hmgujt", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.92, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 598, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 598, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594099445.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>Well, alright, at various points in my life I may have been more pleased, but Windows has been losing my support for years one small nitpick at a time. Just wanted to share the change for whoever cares.</p>\n\n<ul>\n<li>I liked the look and massive size of the windows less and less </li>\n<li>As a programmer using bash and zsh on cygwin became more and more annoying</li>\n<li>Windows keeps randomly changing stuff that I never wanted, like my downloads folder becoming a date-sorted list instead of an actual folder (and switching it back when I changed it!)</li>\n<li>Adding cortana and the like and making it difficult to disable</li>\n<li>Windows update.</li>\n<li>Almost every bit of software I have at this point is also on linux or through a browser!</li>\n</ul>\n\n<p>I switched to Manjaro-Gnome and never looked back.</p>\n\n<ul>\n<li>It&#39;s sleeker/runs faster.</li>\n<li>Uses less RAM</li>\n<li>Uses rolling updates</li>\n<li>I can finally just use a built-in terminal</li>\n<li>Has an easier to understand file structure, despite its complexity.</li>\n<li>Is surprisingly easy to use. The only difficult part really was finding the wifi driver, and that was actually because it was mislabeled by the manufacturer.</li>\n<li>Gnome is definitely nicer to use than Windows 10.</li>\n<li>Searching for files and programs works well! I really didn&#39;t need windows to fail to find a program I had installed and instead offer to search for it online... through Bing on Edge.</li>\n</ul>\n\n<p>I never knew how much bloat Windows had until I switched over. This is so damn nice. I don&#39;t know why I didn&#39;t consider Linux as a serious alternative until recently. Steam Proton has also come a long, long way, I haven&#39;t had issues with a game yet.</p>\n\n<p>Anyways, I just wanted to rant, and I&#39;m probably going to install an Manjaro-xfce on a bunch of old laptops.</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hmgujt", + "is_robot_indexable": True, + "report_reasons": None, + "author": "ForShotgun", + "discussion_type": None, + "num_comments": 213, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hmgujt/switched_from_windows_10_to_manjaro_never_been/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.reddit.com/r/linux/comments/hmgujt/switched_from_windows_10_to_manjaro_never_been/", + "subreddit_subscribers": 543995, + "created_utc": 1594070645.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + ], + "after": "t3_hmgujt", + "before": None, + }, +} diff --git a/src/newsreader/news/collection/tests/reddit/stream/tests.py b/src/newsreader/news/collection/tests/reddit/stream/tests.py new file mode 100644 index 0000000..19aff61 --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/stream/tests.py @@ -0,0 +1,144 @@ +from json.decoder import JSONDecodeError +from unittest.mock import patch +from uuid import uuid4 + +from django.test import TestCase + +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamException, + StreamForbiddenException, + StreamNotFoundException, + StreamParseException, + StreamTimeOutException, +) +from newsreader.news.collection.reddit import RedditStream +from newsreader.news.collection.tests.factories import SubredditFactory +from newsreader.news.collection.tests.reddit.stream.mocks import simple_mock + + +class RedditStreamTestCase(TestCase): + def setUp(self): + self.maxDiff = None + + self.patched_fetch = patch("newsreader.news.collection.reddit.fetch") + self.mocked_fetch = self.patched_fetch.start() + + def tearDown(self): + patch.stopall() + + def test_simple_stream(self): + self.mocked_fetch.return_value.json.return_value = simple_mock + + access_token = str(uuid4()) + user = UserFactory(reddit_access_token=access_token) + + subreddit = SubredditFactory(user=user) + stream = RedditStream(subreddit) + + data, stream = stream.read() + + self.assertEquals(data, simple_mock) + self.assertEquals(stream, stream) + self.mocked_fetch.assert_called_once_with( + subreddit.url, headers={"Authorization": f"bearer {access_token}"} + ) + + def test_stream_raises_exception(self): + self.mocked_fetch.side_effect = StreamException + + access_token = str(uuid4()) + user = UserFactory(reddit_access_token=access_token) + + subreddit = SubredditFactory(user=user) + stream = RedditStream(subreddit) + + with self.assertRaises(StreamException): + stream.read() + + self.mocked_fetch.assert_called_once_with( + subreddit.url, headers={"Authorization": f"bearer {access_token}"} + ) + + def test_stream_raises_denied_exception(self): + self.mocked_fetch.side_effect = StreamDeniedException + + access_token = str(uuid4()) + user = UserFactory(reddit_access_token=access_token) + + subreddit = SubredditFactory(user=user) + stream = RedditStream(subreddit) + + with self.assertRaises(StreamDeniedException): + stream.read() + + self.mocked_fetch.assert_called_once_with( + subreddit.url, headers={"Authorization": f"bearer {access_token}"} + ) + + def test_stream_raises_not_found_exception(self): + self.mocked_fetch.side_effect = StreamNotFoundException + + access_token = str(uuid4()) + user = UserFactory(reddit_access_token=access_token) + + subreddit = SubredditFactory(user=user) + stream = RedditStream(subreddit) + + with self.assertRaises(StreamNotFoundException): + stream.read() + + self.mocked_fetch.assert_called_once_with( + subreddit.url, headers={"Authorization": f"bearer {access_token}"} + ) + + def test_stream_raises_time_out_exception(self): + self.mocked_fetch.side_effect = StreamTimeOutException + + access_token = str(uuid4()) + user = UserFactory(reddit_access_token=access_token) + + subreddit = SubredditFactory(user=user) + stream = RedditStream(subreddit) + + with self.assertRaises(StreamTimeOutException): + stream.read() + + self.mocked_fetch.assert_called_once_with( + subreddit.url, headers={"Authorization": f"bearer {access_token}"} + ) + + def test_stream_raises_forbidden_exception(self): + self.mocked_fetch.side_effect = StreamForbiddenException + + access_token = str(uuid4()) + user = UserFactory(reddit_access_token=access_token) + + subreddit = SubredditFactory(user=user) + stream = RedditStream(subreddit) + + with self.assertRaises(StreamForbiddenException): + stream.read() + + self.mocked_fetch.assert_called_once_with( + subreddit.url, headers={"Authorization": f"bearer {access_token}"} + ) + + def test_stream_raises_parse_exception(self): + self.mocked_fetch.return_value.json.side_effect = JSONDecodeError( + "No json found", "{}", 5 + ) + + access_token = str(uuid4()) + user = UserFactory(reddit_access_token=access_token) + + subreddit = SubredditFactory(user=user) + stream = RedditStream(subreddit) + + with self.assertRaises(StreamParseException): + stream.read() + + self.mocked_fetch.assert_called_once_with( + subreddit.url, headers={"Authorization": f"bearer {access_token}"} + ) diff --git a/src/newsreader/news/collection/tests/reddit/test_scheduler.py b/src/newsreader/news/collection/tests/reddit/test_scheduler.py new file mode 100644 index 0000000..cd062b6 --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/test_scheduler.py @@ -0,0 +1,142 @@ +from datetime import timedelta + +from django.test import TestCase +from django.utils import timezone + +from freezegun import freeze_time + +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.reddit import RedditScheduler +from newsreader.news.collection.tests.factories import CollectionRuleFactory + + +@freeze_time("2019-10-30 12:30:00") +class RedditSchedulerTestCase(TestCase): + def test_simple(self): + user_1 = UserFactory( + reddit_access_token="1231414", reddit_refresh_token="5235262" + ) + user_2 = UserFactory( + reddit_access_token="3414777", reddit_refresh_token="3423425" + ) + + user_1_rules = [ + CollectionRuleFactory( + user=user_1, + type=RuleTypeChoices.subreddit, + last_suceeded=timezone.now() - timedelta(days=4), + enabled=True, + ), + CollectionRuleFactory( + user=user_1, + type=RuleTypeChoices.subreddit, + last_suceeded=timezone.now() - timedelta(days=3), + enabled=True, + ), + CollectionRuleFactory( + user=user_1, + type=RuleTypeChoices.subreddit, + last_suceeded=timezone.now() - timedelta(days=2), + enabled=True, + ), + ] + + user_2_rules = [ + CollectionRuleFactory( + user=user_2, + type=RuleTypeChoices.subreddit, + last_suceeded=timezone.now() - timedelta(days=4), + enabled=True, + ), + CollectionRuleFactory( + user=user_2, + type=RuleTypeChoices.subreddit, + last_suceeded=timezone.now() - timedelta(days=3), + enabled=True, + ), + CollectionRuleFactory( + user=user_2, + type=RuleTypeChoices.subreddit, + last_suceeded=timezone.now() - timedelta(days=2), + enabled=True, + ), + ] + + scheduler = RedditScheduler() + scheduled_subreddits = scheduler.get_scheduled_rules() + + user_1_batch = [subreddit.pk for subreddit in scheduled_subreddits[0]] + + self.assertIn(user_1_rules[0].pk, user_1_batch) + self.assertIn(user_1_rules[1].pk, user_1_batch) + self.assertIn(user_1_rules[2].pk, user_1_batch) + + user_2_batch = [subreddit.pk for subreddit in scheduled_subreddits[1]] + + self.assertIn(user_2_rules[0].pk, user_2_batch) + self.assertIn(user_2_rules[1].pk, user_2_batch) + self.assertIn(user_2_rules[2].pk, user_2_batch) + + def test_max_amount(self): + users = UserFactory.create_batch( + reddit_access_token="1231414", reddit_refresh_token="5235262", size=5 + ) + + nested_rules = [ + CollectionRuleFactory.create_batch( + name=f"rule-{index}", + type=RuleTypeChoices.subreddit, + last_suceeded=timezone.now() - timedelta(seconds=index), + enabled=True, + user=user, + size=15, + ) + for index, user in enumerate(users) + ] + + rules = [rule for rule_list in nested_rules for rule in rule_list] + + scheduler = RedditScheduler() + scheduled_subreddits = [ + subreddit.pk + for batch in scheduler.get_scheduled_rules() + for subreddit in batch + ] + + for rule in rules[16:76]: + with self.subTest(rule=rule): + self.assertIn(rule.pk, scheduled_subreddits) + + for rule in rules[0:15]: + with self.subTest(rule=rule): + self.assertNotIn(rule.pk, scheduled_subreddits) + + def test_max_user_amount(self): + user = UserFactory( + reddit_access_token="1231414", reddit_refresh_token="5235262" + ) + + rules = [ + CollectionRuleFactory( + name=f"rule-{index}", + type=RuleTypeChoices.subreddit, + last_suceeded=timezone.now() - timedelta(seconds=index), + enabled=True, + user=user, + ) + for index in range(1, 17) + ] + + scheduler = RedditScheduler() + scheduled_subreddits = [ + subreddit.pk + for batch in scheduler.get_scheduled_rules() + for subreddit in batch + ] + + for rule in rules[1:16]: + with self.subTest(rule=rule): + self.assertIn(rule.pk, scheduled_subreddits) + + self.assertNotIn(rules[0].pk, scheduled_subreddits) diff --git a/src/newsreader/news/collection/tests/utils/tests.py b/src/newsreader/news/collection/tests/utils/tests.py index 95c5dd2..10013c3 100644 --- a/src/newsreader/news/collection/tests/utils/tests.py +++ b/src/newsreader/news/collection/tests/utils/tests.py @@ -6,97 +6,118 @@ from requests.exceptions import ConnectionError as RequestConnectionError from requests.exceptions import HTTPError, RequestException, SSLError, TooManyRedirects from newsreader.news.collection.exceptions import ( - StreamConnectionError, + StreamConnectionException, StreamDeniedException, StreamException, StreamForbiddenException, StreamNotFoundException, StreamTimeOutException, + StreamTooManyException, ) -from newsreader.news.collection.utils import fetch +from newsreader.news.collection.utils import fetch, post -class FetchTestCase(TestCase): - def setUp(self): - self.patched_get = patch("newsreader.news.collection.utils.requests.get") - self.mocked_get = self.patched_get.start() - +class HelperFunctionTestCase: def test_simple(self): - self.mocked_get.return_value = MagicMock(status_code=200, content="content") + self.mocked_method.return_value = MagicMock(status_code=200, content="content") url = "https://www.bbc.co.uk/news" - response = fetch(url) + response = self.method(url) self.assertEquals(response.content, "content") def test_raises_not_found(self): - self.mocked_get.return_value = MagicMock(status_code=404) + self.mocked_method.return_value = MagicMock(status_code=404) url = "https://www.bbc.co.uk/news" with self.assertRaises(StreamNotFoundException): - fetch(url) + self.method(url) def test_raises_denied(self): - self.mocked_get.return_value = MagicMock(status_code=401) + self.mocked_method.return_value = MagicMock(status_code=401) url = "https://www.bbc.co.uk/news" with self.assertRaises(StreamDeniedException): - fetch(url) + self.method(url) def test_raises_forbidden(self): - self.mocked_get.return_value = MagicMock(status_code=403) + self.mocked_method.return_value = MagicMock(status_code=403) url = "https://www.bbc.co.uk/news" with self.assertRaises(StreamForbiddenException): - fetch(url) + self.method(url) def test_raises_timed_out(self): - self.mocked_get.return_value = MagicMock(status_code=408) + self.mocked_method.return_value = MagicMock(status_code=408) url = "https://www.bbc.co.uk/news" with self.assertRaises(StreamTimeOutException): - fetch(url) + self.method(url) def test_raises_stream_error_on_ssl_error(self): - self.mocked_get.side_effect = SSLError + self.mocked_method.side_effect = SSLError url = "https://www.bbc.co.uk/news" with self.assertRaises(StreamException): - fetch(url) + self.method(url) def test_raises_stream_error_on_connection_error(self): - self.mocked_get.side_effect = RequestConnectionError + self.mocked_method.side_effect = RequestConnectionError url = "https://www.bbc.co.uk/news" - with self.assertRaises(StreamConnectionError): - fetch(url) + with self.assertRaises(StreamConnectionException): + self.method(url) def test_raises_stream_error_on_http_error(self): - self.mocked_get.side_effect = HTTPError + self.mocked_method.side_effect = HTTPError url = "https://www.bbc.co.uk/news" with self.assertRaises(StreamException): - fetch(url) + self.method(url) def test_raises_stream_error_on_request_exception(self): - self.mocked_get.side_effect = RequestException + self.mocked_method.side_effect = RequestException url = "https://www.bbc.co.uk/news" with self.assertRaises(StreamException): - fetch(url) + self.method(url) def test_raises_stream_error_on_too_many_redirects(self): - self.mocked_get.side_effect = TooManyRedirects + self.mocked_method.side_effect = TooManyRedirects url = "https://www.bbc.co.uk/news" with self.assertRaises(StreamException): - fetch(url) + self.method(url) + + def test_raises_stream_error_on_too_many_requests(self): + self.mocked_method.return_value = MagicMock(status_code=429) + + url = "https://www.bbc.co.uk/news" + + with self.assertRaises(StreamTooManyException): + self.method(url) + + +class FetchTestCase(HelperFunctionTestCase, TestCase): + def setUp(self): + self.patch = patch("newsreader.news.collection.utils.requests.get") + self.mocked_method = self.patch.start() + + self.method = fetch + + +class PostTestCase(HelperFunctionTestCase, TestCase): + def setUp(self): + self.patch = patch("newsreader.news.collection.utils.requests.post") + self.mocked_method = self.patch.start() + + self.method = post diff --git a/src/newsreader/news/collection/tests/views/__init__.py b/src/newsreader/news/collection/tests/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/views/base.py b/src/newsreader/news/collection/tests/views/base.py new file mode 100644 index 0000000..d7de171 --- /dev/null +++ b/src/newsreader/news/collection/tests/views/base.py @@ -0,0 +1,69 @@ +from django.urls import reverse + +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.core.tests.factories import CategoryFactory + + +class CollectionRuleViewTestCase: + def setUp(self): + self.user = UserFactory(password="test") + self.client.force_login(self.user) + + self.category = CategoryFactory(user=self.user) + self.form_data = {"name": "", "category": "", "url": "", "timezone": ""} + + def test_simple(self): + response = self.client.get(self.url) + + self.assertEquals(response.status_code, 200) + + def test_no_category(self): + self.form_data.update(category="") + + response = self.client.post(self.url, self.form_data) + self.assertEquals(response.status_code, 302) + + rule = CollectionRule.objects.get() + + self.assertEquals(rule.category, None) + + def test_categories_only_from_user(self): + other_user = UserFactory() + other_categories = CategoryFactory.create_batch(size=4, user=other_user) + + response = self.client.get(self.url) + + for category in other_categories: + self.assertNotContains(response, category.name) + + def test_category_of_other_user(self): + other_user = UserFactory() + other_rule = CollectionRuleFactory(name="other rule", user=other_user) + + self.form_data.update( + name="new name", + category=other_rule.category, + url=other_rule.url, + timezone=other_rule.timezone, + ) + + other_url = reverse("news:collection:rule-update", args=[other_rule.pk]) + response = self.client.post(other_url, self.form_data) + + self.assertEquals(response.status_code, 404) + + other_rule.refresh_from_db() + + self.assertEquals(other_rule.name, "other rule") + + def test_with_other_user_rules(self): + other_user = UserFactory() + other_categories = CategoryFactory.create_batch(size=4, user=other_user) + + self.form_data.update(category=other_categories[2].pk) + + response = self.client.post(self.url, self.form_data) + + self.assertContains(response, "not one of the available choices") diff --git a/src/newsreader/news/collection/tests/views/test_bulk_views.py b/src/newsreader/news/collection/tests/views/test_bulk_views.py index 39817c2..5112feb 100644 --- a/src/newsreader/news/collection/tests/views/test_bulk_views.py +++ b/src/newsreader/news/collection/tests/views/test_bulk_views.py @@ -4,7 +4,7 @@ from django.utils.translation import gettext_lazy as _ from newsreader.accounts.tests.factories import UserFactory from newsreader.news.collection.models import CollectionRule -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory class CollectionRuleBulkViewTestCase: @@ -21,9 +21,7 @@ class CollectionRuleBulkEnableViewTestCase(CollectionRuleBulkViewTestCase, TestC self.url = reverse("news:collection:rules-enable") - self.rules = CollectionRuleFactory.create_batch( - size=5, user=self.user, enabled=False - ) + self.rules = FeedFactory.create_batch(size=5, user=self.user, enabled=False) def test_simple(self): response = self.client.post( @@ -55,9 +53,7 @@ class CollectionRuleBulkEnableViewTestCase(CollectionRuleBulkViewTestCase, TestC def test_rule_from_other_user(self): other_user = UserFactory() - other_rules = CollectionRuleFactory.create_batch( - size=5, user=other_user, enabled=False - ) + other_rules = FeedFactory.create_batch(size=5, user=other_user, enabled=False) response = self.client.post( self.url, @@ -100,9 +96,7 @@ class CollectionRuleBulkDisableViewTestCase(CollectionRuleBulkViewTestCase, Test self.url = reverse("news:collection:rules-disable") - self.rules = CollectionRuleFactory.create_batch( - size=5, user=self.user, enabled=True - ) + self.rules = FeedFactory.create_batch(size=5, user=self.user, enabled=True) def test_simple(self): response = self.client.post( @@ -134,9 +128,7 @@ class CollectionRuleBulkDisableViewTestCase(CollectionRuleBulkViewTestCase, Test def test_rule_from_other_user(self): other_user = UserFactory() - other_rules = CollectionRuleFactory.create_batch( - size=5, user=other_user, enabled=True - ) + other_rules = FeedFactory.create_batch(size=5, user=other_user, enabled=True) response = self.client.post( self.url, @@ -179,7 +171,7 @@ class CollectionRuleBulkDeleteViewTestCase(CollectionRuleBulkViewTestCase, TestC self.url = reverse("news:collection:rules-delete") - self.rules = CollectionRuleFactory.create_batch(size=5, user=self.user) + self.rules = FeedFactory.create_batch(size=5, user=self.user) def test_simple(self): response = self.client.post( @@ -207,9 +199,7 @@ class CollectionRuleBulkDeleteViewTestCase(CollectionRuleBulkViewTestCase, TestC def test_rule_from_other_user(self): other_user = UserFactory() - other_rules = CollectionRuleFactory.create_batch( - size=5, user=other_user, enabled=True - ) + other_rules = FeedFactory.create_batch(size=5, user=other_user, enabled=True) response = self.client.post( self.url, diff --git a/src/newsreader/news/collection/tests/views/test_crud.py b/src/newsreader/news/collection/tests/views/test_crud.py index a581f0c..61f6835 100644 --- a/src/newsreader/news/collection/tests/views/test_crud.py +++ b/src/newsreader/news/collection/tests/views/test_crud.py @@ -3,80 +3,18 @@ from django.urls import reverse import pytz -from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.models import CollectionRule -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory +from newsreader.news.collection.tests.views.base import CollectionRuleViewTestCase from newsreader.news.core.tests.factories import CategoryFactory -class CollectionRuleViewTestCase: - def setUp(self): - self.user = UserFactory(password="test") - self.client.force_login(self.user) - - self.category = CategoryFactory(user=self.user) - self.form_data = {"name": "", "category": "", "url": "", "timezone": ""} - - def test_simple(self): - response = self.client.get(self.url) - - self.assertEquals(response.status_code, 200) - - def test_no_category(self): - self.form_data.update(category="") - - response = self.client.post(self.url, self.form_data) - self.assertEquals(response.status_code, 302) - - rule = CollectionRule.objects.get() - - self.assertEquals(rule.category, None) - - def test_categories_only_from_user(self): - other_user = UserFactory() - other_categories = CategoryFactory.create_batch(size=4, user=other_user) - - response = self.client.get(self.url) - - for category in other_categories: - self.assertNotContains(response, category.name) - - def test_category_of_other_user(self): - other_user = UserFactory() - other_rule = CollectionRuleFactory(name="other rule", user=other_user) - - self.form_data.update( - name="new name", - category=other_rule.category, - url=other_rule.url, - timezone=other_rule.timezone, - ) - - other_url = reverse("rule-update", args=[other_rule.pk]) - response = self.client.post(other_url, self.form_data) - - self.assertEquals(response.status_code, 404) - - other_rule.refresh_from_db() - - self.assertEquals(other_rule.name, "other rule") - - def test_with_other_user_rules(self): - other_user = UserFactory() - other_categories = CategoryFactory.create_batch(size=4, user=other_user) - - self.form_data.update(category=other_categories[2].pk) - - response = self.client.post(self.url, self.form_data) - - self.assertContains(response, "not one of the available choices") - - class CollectionRuleCreateViewTestCase(CollectionRuleViewTestCase, TestCase): def setUp(self): super().setUp() - self.url = reverse("rule-create") + self.url = reverse("news:collection:rule-create") self.form_data.update( name="new rule", @@ -92,6 +30,7 @@ class CollectionRuleCreateViewTestCase(CollectionRuleViewTestCase, TestCase): rule = CollectionRule.objects.get(name="new rule") + self.assertEquals(rule.type, RuleTypeChoices.feed) self.assertEquals(rule.url, "https://www.rss.com/rss") self.assertEquals(rule.timezone, str(pytz.utc)) self.assertEquals(rule.favicon, None) @@ -103,10 +42,10 @@ class CollectionRuleUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): def setUp(self): super().setUp() - self.rule = CollectionRuleFactory( + self.rule = FeedFactory( name="collection rule", user=self.user, category=self.category ) - self.url = reverse("rule-update", args=[self.rule.pk]) + self.url = reverse("news:collection:rule-update", kwargs={"pk": self.rule.pk}) self.form_data.update( name=self.rule.name, @@ -146,3 +85,17 @@ class CollectionRuleUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): self.rule.refresh_from_db() self.assertEquals(self.rule.category, None) + + def test_rules_only(self): + rule = FeedFactory( + name="Python", + url="https://reddit.com/r/python", + user=self.user, + category=self.category, + type=RuleTypeChoices.subreddit, + ) + url = reverse("news:collection:rule-update", kwargs={"pk": rule.pk}) + + response = self.client.get(url) + + self.assertEquals(response.status_code, 404) diff --git a/src/newsreader/news/collection/tests/views/test_import_view.py b/src/newsreader/news/collection/tests/views/test_import_view.py index 776e4c6..f4188e7 100644 --- a/src/newsreader/news/collection/tests/views/test_import_view.py +++ b/src/newsreader/news/collection/tests/views/test_import_view.py @@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _ from newsreader.accounts.tests.factories import UserFactory from newsreader.news.collection.models import CollectionRule -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory class OPMLImportTestCase(TestCase): @@ -16,7 +16,7 @@ class OPMLImportTestCase(TestCase): self.client.force_login(self.user) self.form_data = {"file": "", "skip_existing": False} - self.url = reverse("import") + self.url = reverse("news:collection:import") def _get_file_path(self, name): file_dir = os.path.join(settings.DJANGO_PROJECT_DIR, "utils", "tests", "files") @@ -30,22 +30,16 @@ class OPMLImportTestCase(TestCase): response = self.client.post(self.url, self.form_data) - self.assertRedirects(response, reverse("rules")) + self.assertRedirects(response, reverse("news:collection:rules")) rules = CollectionRule.objects.all() self.assertEquals(len(rules), 4) def test_existing_rules(self): - CollectionRuleFactory( - url="http://www.engadget.com/rss-full.xml", user=self.user - ) - CollectionRuleFactory(url="https://news.ycombinator.com/rss", user=self.user) - CollectionRuleFactory( - url="http://feeds.feedburner.com/Techcrunch", user=self.user - ) - CollectionRuleFactory( - url="http://feeds.feedburner.com/tweakers/nieuws", user=self.user - ) + FeedFactory(url="http://www.engadget.com/rss-full.xml", user=self.user) + FeedFactory(url="https://news.ycombinator.com/rss", user=self.user) + FeedFactory(url="http://feeds.feedburner.com/Techcrunch", user=self.user) + FeedFactory(url="http://feeds.feedburner.com/tweakers/nieuws", user=self.user) file_path = self._get_file_path("feeds.opml") @@ -54,22 +48,16 @@ class OPMLImportTestCase(TestCase): response = self.client.post(self.url, self.form_data) - self.assertRedirects(response, reverse("rules")) + self.assertRedirects(response, reverse("news:collection:rules")) rules = CollectionRule.objects.all() self.assertEquals(len(rules), 8) def test_skip_existing_rules(self): - CollectionRuleFactory( - url="http://www.engadget.com/rss-full.xml", user=self.user - ) - CollectionRuleFactory(url="https://news.ycombinator.com/rss", user=self.user) - CollectionRuleFactory( - url="http://feeds.feedburner.com/Techcrunch", user=self.user - ) - CollectionRuleFactory( - url="http://feeds.feedburner.com/tweakers/nieuws", user=self.user - ) + FeedFactory(url="http://www.engadget.com/rss-full.xml", user=self.user) + FeedFactory(url="https://news.ycombinator.com/rss", user=self.user) + FeedFactory(url="http://feeds.feedburner.com/Techcrunch", user=self.user) + FeedFactory(url="http://feeds.feedburner.com/tweakers/nieuws", user=self.user) file_path = self._get_file_path("feeds.opml") @@ -136,7 +124,7 @@ class OPMLImportTestCase(TestCase): response = self.client.post(self.url, self.form_data) - self.assertRedirects(response, reverse("rules")) + self.assertRedirects(response, reverse("news:collection:rules")) rules = CollectionRule.objects.all() self.assertEquals(len(rules), 2) diff --git a/src/newsreader/news/collection/tests/views/test_subreddit_views.py b/src/newsreader/news/collection/tests/views/test_subreddit_views.py new file mode 100644 index 0000000..a8de55e --- /dev/null +++ b/src/newsreader/news/collection/tests/views/test_subreddit_views.py @@ -0,0 +1,113 @@ +from django.test import TestCase +from django.urls import reverse + +import pytz + +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.reddit import REDDIT_URL +from newsreader.news.collection.tests.factories import SubredditFactory +from newsreader.news.collection.tests.views.base import CollectionRuleViewTestCase +from newsreader.news.core.tests.factories import CategoryFactory + + +class SubRedditCreateViewTestCase(CollectionRuleViewTestCase, TestCase): + def setUp(self): + super().setUp() + + self.form_data = { + "name": "new rule", + "url": "https://www.reddit.com/r/aww", + "category": str(self.category.pk), + } + + self.url = reverse("news:collection:subreddit-create") + + def test_creation(self): + response = self.client.post(self.url, self.form_data) + + self.assertEquals(response.status_code, 302) + + rule = CollectionRule.objects.get(name="new rule") + + self.assertEquals(rule.type, RuleTypeChoices.subreddit) + self.assertEquals(rule.url, "https://www.reddit.com/r/aww.json") + self.assertEquals(rule.timezone, str(pytz.utc)) + self.assertEquals(rule.favicon, None) + self.assertEquals(rule.category.pk, self.category.pk) + self.assertEquals(rule.user.pk, self.user.pk) + + +class SubRedditUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): + def setUp(self): + super().setUp() + + self.rule = SubredditFactory( + name="Python", + url=f"{REDDIT_URL}/r/python.json", + user=self.user, + category=self.category, + type=RuleTypeChoices.subreddit, + ) + self.url = reverse( + "news:collection:subreddit-update", kwargs={"pk": self.rule.pk} + ) + + self.form_data = { + "name": self.rule.name, + "url": self.rule.url, + "category": str(self.category.pk), + "timezone": pytz.utc, + } + + def test_name_change(self): + self.form_data.update(name="Python 2") + + response = self.client.post(self.url, self.form_data) + self.assertEquals(response.status_code, 302) + + self.rule.refresh_from_db() + + self.assertEquals(self.rule.name, "Python 2") + + def test_category_change(self): + new_category = CategoryFactory(user=self.user) + + self.form_data.update(category=new_category.pk) + + response = self.client.post(self.url, self.form_data) + self.assertEquals(response.status_code, 302) + + self.rule.refresh_from_db() + + self.assertEquals(self.rule.category.pk, new_category.pk) + + def test_subreddit_rules_only(self): + rule = SubredditFactory( + name="Fake subreddit", + url="https://leddit.com/r/python", + user=self.user, + category=self.category, + type=RuleTypeChoices.feed, + ) + url = reverse("news:collection:subreddit-update", kwargs={"pk": rule.pk}) + + response = self.client.get(url) + + self.assertEquals(response.status_code, 404) + + def test_url_change(self): + self.form_data.update(name="aww", url=f"{REDDIT_URL}/r/aww") + + response = self.client.post(self.url, self.form_data) + + self.assertEquals(response.status_code, 302) + + rule = CollectionRule.objects.get(name="aww") + + self.assertEquals(rule.type, RuleTypeChoices.subreddit) + self.assertEquals(rule.url, f"{REDDIT_URL}/r/aww.json") + self.assertEquals(rule.timezone, str(pytz.utc)) + self.assertEquals(rule.favicon, None) + self.assertEquals(rule.category.pk, self.category.pk) + self.assertEquals(rule.user.pk, self.user.pk) diff --git a/src/newsreader/news/collection/urls.py b/src/newsreader/news/collection/urls.py index 1ea17d6..5253210 100644 --- a/src/newsreader/news/collection/urls.py +++ b/src/newsreader/news/collection/urls.py @@ -15,6 +15,8 @@ from newsreader.news.collection.views import ( CollectionRuleListView, CollectionRuleUpdateView, OPMLImportView, + SubRedditCreateView, + SubRedditUpdateView, ) @@ -52,5 +54,15 @@ urlpatterns = [ login_required(CollectionRuleBulkDisableView.as_view()), name="rules-disable", ), + path( + "rules/subreddits/create/", + login_required(SubRedditCreateView.as_view()), + name="subreddit-create", + ), + path( + "rules/subreddits//", + login_required(SubRedditUpdateView.as_view()), + name="subreddit-update", + ), path("rules/import/", login_required(OPMLImportView.as_view()), name="import"), ] diff --git a/src/newsreader/news/collection/utils.py b/src/newsreader/news/collection/utils.py index 9a2e456..8ba6fec 100644 --- a/src/newsreader/news/collection/utils.py +++ b/src/newsreader/news/collection/utils.py @@ -1,5 +1,7 @@ from datetime import datetime +from django.db.models.fields import CharField, TextField +from django.template.defaultfilters import truncatechars from django.utils import timezone import pytz @@ -10,6 +12,9 @@ from requests.exceptions import RequestException from newsreader.news.collection.response_handler import ResponseHandler +DEFAULT_HEADERS = {"User-Agent": "linux:rss.fudiggity.nl:v0.2"} + + def build_publication_date(dt, tz): try: naive_datetime = datetime(*dt[:6]) @@ -20,12 +25,46 @@ def build_publication_date(dt, tz): return published_parsed.astimezone(pytz.utc) -def fetch(url): +def fetch(url, headers={}): + headers = {**DEFAULT_HEADERS, **headers} + with ResponseHandler() as response_handler: try: - response = requests.get(url) + response = requests.get(url, headers=headers) response_handler.handle_response(response) except RequestException as exception: - response_handler.handle_exception(exception) + response_handler.map_exception(exception) return response + + +def post(url, data=None, auth=None, headers={}): + headers = {**DEFAULT_HEADERS, **headers} + + with ResponseHandler() as response_handler: + try: + response = requests.post(url, data=data, auth=auth, headers=headers) + response_handler.handle_response(response) + except RequestException as exception: + response_handler.map_exception(exception) + + return response + + +def truncate_text(cls, field_name, value): + field = cls._meta.get_field(field_name) + max_length = field.max_length + field_cls = type(field) + + is_charfield = bool(issubclass(field_cls, CharField)) + is_textfield = bool(issubclass(field_cls, TextField)) + + if not value or not max_length: + return value + elif not is_charfield or is_textfield: + return value + + if len(value) > max_length: + return truncatechars(value, max_length) + + return value diff --git a/src/newsreader/news/collection/views/__init__.py b/src/newsreader/news/collection/views/__init__.py new file mode 100644 index 0000000..20769f3 --- /dev/null +++ b/src/newsreader/news/collection/views/__init__.py @@ -0,0 +1,13 @@ +from newsreader.news.collection.views.reddit import ( + SubRedditCreateView, + SubRedditUpdateView, +) +from newsreader.news.collection.views.rules import ( + CollectionRuleBulkDeleteView, + CollectionRuleBulkDisableView, + CollectionRuleBulkEnableView, + CollectionRuleCreateView, + CollectionRuleListView, + CollectionRuleUpdateView, + OPMLImportView, +) diff --git a/src/newsreader/news/collection/views/base.py b/src/newsreader/news/collection/views/base.py new file mode 100644 index 0000000..e7f7b63 --- /dev/null +++ b/src/newsreader/news/collection/views/base.py @@ -0,0 +1,36 @@ +from django.urls import reverse_lazy + +import pytz + +from newsreader.news.collection.forms import CollectionRuleForm +from newsreader.news.collection.models import CollectionRule +from newsreader.news.core.models import Category + + +class CollectionRuleViewMixin: + queryset = CollectionRule.objects.order_by("name") + + def get_queryset(self): + user = self.request.user + return self.queryset.filter(user=user) + + +class CollectionRuleDetailMixin: + success_url = reverse_lazy("news:collection:rules") + form_class = CollectionRuleForm + + def get_context_data(self, **kwargs): + context_data = super().get_context_data(**kwargs) + + categories = Category.objects.filter(user=self.request.user).order_by("name") + timezones = [timezone for timezone in pytz.all_timezones] + + context_data["categories"] = categories + context_data["timezones"] = timezones + + return context_data + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["user"] = self.request.user + return kwargs diff --git a/src/newsreader/news/collection/views/reddit.py b/src/newsreader/news/collection/views/reddit.py new file mode 100644 index 0000000..533513b --- /dev/null +++ b/src/newsreader/news/collection/views/reddit.py @@ -0,0 +1,26 @@ +from django.views.generic.edit import CreateView, UpdateView + +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.forms import SubRedditRuleForm +from newsreader.news.collection.views.base import ( + CollectionRuleDetailMixin, + CollectionRuleViewMixin, +) + + +class SubRedditCreateView( + CollectionRuleViewMixin, CollectionRuleDetailMixin, CreateView +): + form_class = SubRedditRuleForm + template_name = "news/collection/views/subreddit-create.html" + + +class SubRedditUpdateView( + CollectionRuleViewMixin, CollectionRuleDetailMixin, UpdateView +): + form_class = SubRedditRuleForm + template_name = "news/collection/views/subreddit-update.html" + + def get_queryset(self): + queryset = super().get_queryset() + return queryset.filter(type=RuleTypeChoices.subreddit) diff --git a/src/newsreader/news/collection/views.py b/src/newsreader/news/collection/views/rules.py similarity index 72% rename from src/newsreader/news/collection/views.py rename to src/newsreader/news/collection/views/rules.py index 6fb88df..e020b67 100644 --- a/src/newsreader/news/collection/views.py +++ b/src/newsreader/news/collection/views/rules.py @@ -1,51 +1,20 @@ from django.contrib import messages from django.shortcuts import redirect -from django.urls import reverse, reverse_lazy -from django.utils.translation import gettext_lazy as _ +from django.urls import reverse +from django.utils.translation import gettext as _ from django.views.generic.edit import CreateView, FormView, UpdateView from django.views.generic.list import ListView -import pytz - -from newsreader.news.collection.forms import ( - CollectionRuleBulkForm, - CollectionRuleForm, - OPMLImportForm, -) +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.forms import CollectionRuleBulkForm, OPMLImportForm from newsreader.news.collection.models import CollectionRule -from newsreader.news.core.models import Category +from newsreader.news.collection.views.base import ( + CollectionRuleDetailMixin, + CollectionRuleViewMixin, +) from newsreader.utils.opml import parse_opml -class CollectionRuleViewMixin: - queryset = CollectionRule.objects.order_by("name") - - def get_queryset(self): - user = self.request.user - return self.queryset.filter(user=user).order_by("name") - - -class CollectionRuleDetailMixin: - success_url = reverse_lazy("news:collection:rules") - form_class = CollectionRuleForm - - def get_context_data(self, **kwargs): - context_data = super().get_context_data(**kwargs) - - rules = Category.objects.filter(user=self.request.user).order_by("name") - timezones = [timezone for timezone in pytz.all_timezones] - - context_data["categories"] = rules - context_data["timezones"] = timezones - - return context_data - - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs["user"] = self.request.user - return kwargs - - class CollectionRuleListView(CollectionRuleViewMixin, ListView): paginate_by = 50 template_name = "news/collection/views/rules.html" @@ -58,6 +27,10 @@ class CollectionRuleUpdateView( template_name = "news/collection/views/rule-update.html" context_object_name = "rule" + def get_queryset(self): + queryset = super().get_queryset() + return queryset.filter(type=RuleTypeChoices.feed) + class CollectionRuleCreateView( CollectionRuleViewMixin, CollectionRuleDetailMixin, CreateView @@ -121,7 +94,6 @@ class CollectionRuleBulkDeleteView(CollectionRuleBulkView): class OPMLImportView(FormView): form_class = OPMLImportForm - success_url = reverse_lazy("news:collection:rules") template_name = "news/collection/views/import.html" def form_valid(self, form): @@ -145,3 +117,6 @@ class OPMLImportView(FormView): messages.success(self.request, message) return super().form_valid(form) + + def get_success_url(self): + return reverse("news:collection:rules") diff --git a/src/newsreader/news/core/migrations/0007_auto_20200706_2312.py b/src/newsreader/news/core/migrations/0007_auto_20200706_2312.py new file mode 100644 index 0000000..751faf9 --- /dev/null +++ b/src/newsreader/news/core/migrations/0007_auto_20200706_2312.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.7 on 2020-07-06 21:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("core", "0006_auto_20200524_1218")] + + operations = [ + migrations.AlterField( + model_name="post", + name="body", + field=models.TextField(blank=True, default=""), + preserve_default=False, + ) + ] diff --git a/src/newsreader/scss/components/section/_text-section.scss b/src/newsreader/scss/components/section/_text-section.scss index 88e3e72..9c5e8fc 100644 --- a/src/newsreader/scss/components/section/_text-section.scss +++ b/src/newsreader/scss/components/section/_text-section.scss @@ -8,4 +8,3 @@ background-color: $white; } - diff --git a/src/newsreader/scss/elements/button/_button.scss b/src/newsreader/scss/elements/button/_button.scss index 2e97d6b..50af49e 100644 --- a/src/newsreader/scss/elements/button/_button.scss +++ b/src/newsreader/scss/elements/button/_button.scss @@ -43,4 +43,13 @@ background-color: lighten($button-blue, 5%); } } + + &--reddit { + color: $white !important; + background-color: lighten($reddit-orange, 5%); + + &:hover { + background-color: $reddit-orange; + } + } } diff --git a/src/newsreader/scss/pages/settings/index.scss b/src/newsreader/scss/pages/settings/index.scss index 28837cd..c52f46b 100644 --- a/src/newsreader/scss/pages/settings/index.scss +++ b/src/newsreader/scss/pages/settings/index.scss @@ -1,11 +1,11 @@ #settings--page { - .settings-form__fieldset:last-child { - & span { - display: flex; - flex-direction: row; - - & >:first-child { - margin: 0 5px; + .form { + &__section { + &--last { + & .fieldset { + gap: 15px; + justify-content: flex-start; + } } } } diff --git a/src/newsreader/scss/partials/_colors.scss b/src/newsreader/scss/partials/_colors.scss index 08c7169..aee33c2 100644 --- a/src/newsreader/scss/partials/_colors.scss +++ b/src/newsreader/scss/partials/_colors.scss @@ -40,3 +40,5 @@ $white: rgba(255, 255, 255, 1); $black: rgba(0, 0, 0, 1); $blue: darken($azureish-white, +50%); $dark: rgba(0, 0, 0, 0.4); + +$reddit-orange: rgba(255, 69, 0, 1); From d4b58624d667142957a8bee7fa7fd2dec657c0b4 Mon Sep 17 00:00:00 2001 From: sonny Date: Sun, 12 Jul 2020 20:22:23 +0200 Subject: [PATCH 118/422] Fix favicon not showing in admin --- src/newsreader/templates/admin/base_site.html | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/newsreader/templates/admin/base_site.html diff --git a/src/newsreader/templates/admin/base_site.html b/src/newsreader/templates/admin/base_site.html new file mode 100644 index 0000000..c9d88b8 --- /dev/null +++ b/src/newsreader/templates/admin/base_site.html @@ -0,0 +1,6 @@ +{% extends "admin/base.html" %} +{% load static %} + +{% block extrahead %} + +{% endblock %} From 177755e302fb05002517c34a7e28473cacc604b4 Mon Sep 17 00:00:00 2001 From: sonny Date: Sun, 12 Jul 2020 20:26:14 +0200 Subject: [PATCH 119/422] Squashed commit of the following: commit 99fd94580f95dcbfb77b73e2de846f76a5709ef9 Author: Sonny Date: Sat Feb 15 21:45:16 2020 +0100 Use postgres password As of https://gitlab.com/gitlab-com/support-forum/issues/5199 --- docker-compose.yml | 4 + src/newsreader/accounts/admin.py | 21 +- .../migrations/0010_auto_20200603_2230.py | 21 + src/newsreader/accounts/models.py | 5 +- .../accounts/components/settings-form.html | 12 + .../templates/accounts/views/reddit.html | 17 + .../accounts/tests/test_settings.py | 161 + src/newsreader/accounts/tests/test_views.py | 29 - src/newsreader/accounts/urls.py | 12 + src/newsreader/accounts/views.py | 97 +- src/newsreader/conf/base.py | 18 +- src/newsreader/conf/production.py | 5 + src/newsreader/js/components/Card.js | 2 +- src/newsreader/news/collection/base.py | 24 +- src/newsreader/news/collection/choices.py | 7 + src/newsreader/news/collection/exceptions.py | 9 +- src/newsreader/news/collection/feed.py | 52 +- src/newsreader/news/collection/forms.py | 40 +- .../migrations/0008_collectionrule_type.py | 20 + src/newsreader/news/collection/models.py | 16 +- src/newsreader/news/collection/reddit.py | 307 ++ .../news/collection/response_handler.py | 16 +- src/newsreader/news/collection/tasks.py | 76 +- .../news/collection/views/rules.html | 3 +- .../collection/views/subreddit-create.html | 9 + .../collection/views/subreddit-update.html | 9 + .../news/collection/tests/factories.py | 11 + .../collection/tests/feed/builder/tests.py | 32 +- .../collection/tests/feed/client/tests.py | 52 +- .../collection/tests/feed/collector/tests.py | 23 +- .../tests/feed/duplicate_handler/tests.py | 14 +- .../collection/tests/feed/stream/mocks.py | 207 +- .../collection/tests/feed/stream/tests.py | 22 +- .../news/collection/tests/reddit/__init__.py | 0 .../tests/reddit/builder/__init__.py | 0 .../collection/tests/reddit/builder/mocks.py | 1378 +++++++ .../collection/tests/reddit/builder/tests.py | 185 + .../tests/reddit/client/__init__.py | 0 .../collection/tests/reddit/client/mocks.py | 160 + .../collection/tests/reddit/client/tests.py | 164 + .../tests/reddit/collector/__init__.py | 0 .../tests/reddit/collector/mocks.py | 1662 +++++++++ .../tests/reddit/collector/tests.py | 204 + .../tests/reddit/stream/__init__.py | 0 .../collection/tests/reddit/stream/mocks.py | 3289 +++++++++++++++++ .../collection/tests/reddit/stream/tests.py | 144 + .../collection/tests/reddit/test_scheduler.py | 142 + .../news/collection/tests/utils/tests.py | 77 +- .../news/collection/tests/views/__init__.py | 0 .../news/collection/tests/views/base.py | 69 + .../collection/tests/views/test_bulk_views.py | 24 +- .../news/collection/tests/views/test_crud.py | 89 +- .../tests/views/test_import_view.py | 38 +- .../tests/views/test_subreddit_views.py | 113 + src/newsreader/news/collection/urls.py | 12 + src/newsreader/news/collection/utils.py | 45 +- .../news/collection/views/__init__.py | 13 + src/newsreader/news/collection/views/base.py | 36 + .../news/collection/views/reddit.py | 26 + .../collection/{views.py => views/rules.py} | 55 +- .../migrations/0007_auto_20200706_2312.py | 17 + .../components/section/_text-section.scss | 1 - .../scss/elements/button/_button.scss | 9 + src/newsreader/scss/pages/settings/index.scss | 14 +- src/newsreader/scss/partials/_colors.scss | 2 + src/newsreader/templates/admin/base_site.html | 6 + 66 files changed, 8955 insertions(+), 372 deletions(-) create mode 100644 src/newsreader/accounts/migrations/0010_auto_20200603_2230.py create mode 100644 src/newsreader/accounts/templates/accounts/views/reddit.html create mode 100644 src/newsreader/accounts/tests/test_settings.py delete mode 100644 src/newsreader/accounts/tests/test_views.py create mode 100644 src/newsreader/news/collection/choices.py create mode 100644 src/newsreader/news/collection/migrations/0008_collectionrule_type.py create mode 100644 src/newsreader/news/collection/reddit.py create mode 100644 src/newsreader/news/collection/templates/news/collection/views/subreddit-create.html create mode 100644 src/newsreader/news/collection/templates/news/collection/views/subreddit-update.html create mode 100644 src/newsreader/news/collection/tests/reddit/__init__.py create mode 100644 src/newsreader/news/collection/tests/reddit/builder/__init__.py create mode 100644 src/newsreader/news/collection/tests/reddit/builder/mocks.py create mode 100644 src/newsreader/news/collection/tests/reddit/builder/tests.py create mode 100644 src/newsreader/news/collection/tests/reddit/client/__init__.py create mode 100644 src/newsreader/news/collection/tests/reddit/client/mocks.py create mode 100644 src/newsreader/news/collection/tests/reddit/client/tests.py create mode 100644 src/newsreader/news/collection/tests/reddit/collector/__init__.py create mode 100644 src/newsreader/news/collection/tests/reddit/collector/mocks.py create mode 100644 src/newsreader/news/collection/tests/reddit/collector/tests.py create mode 100644 src/newsreader/news/collection/tests/reddit/stream/__init__.py create mode 100644 src/newsreader/news/collection/tests/reddit/stream/mocks.py create mode 100644 src/newsreader/news/collection/tests/reddit/stream/tests.py create mode 100644 src/newsreader/news/collection/tests/reddit/test_scheduler.py create mode 100644 src/newsreader/news/collection/tests/views/__init__.py create mode 100644 src/newsreader/news/collection/tests/views/base.py create mode 100644 src/newsreader/news/collection/tests/views/test_subreddit_views.py create mode 100644 src/newsreader/news/collection/views/__init__.py create mode 100644 src/newsreader/news/collection/views/base.py create mode 100644 src/newsreader/news/collection/views/reddit.py rename src/newsreader/news/collection/{views.py => views/rules.py} (72%) create mode 100644 src/newsreader/news/core/migrations/0007_auto_20200706_2312.py create mode 100644 src/newsreader/templates/admin/base_site.html diff --git a/docker-compose.yml b/docker-compose.yml index 27d2969..c7dc5ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,8 @@ services: - DJANGO_SETTINGS_MODULE=newsreader.conf.docker depends_on: - rabbitmq + volumes: + - .:/app django: build: context: . @@ -45,6 +47,8 @@ services: volumes: - .:/app - static-files:/app/src/newsreader/static + stdin_open: true + tty: true webpack: build: context: . diff --git a/src/newsreader/accounts/admin.py b/src/newsreader/accounts/admin.py index c223687..e0b5eed 100644 --- a/src/newsreader/accounts/admin.py +++ b/src/newsreader/accounts/admin.py @@ -1,9 +1,19 @@ +from django import forms from django.contrib import admin from django.utils.translation import ugettext as _ from newsreader.accounts.models import User +class UserAdminForm(forms.ModelForm): + class Meta: + widgets = { + "email": forms.EmailInput(attrs={"size": "50"}), + "reddit_access_token": forms.TextInput(attrs={"size": "90"}), + "reddit_refresh_token": forms.TextInput(attrs={"size": "90"}), + } + + class UserAdmin(admin.ModelAdmin): list_display = ("email", "last_name", "date_joined", "is_active") list_filter = ("is_active", "is_staff", "is_superuser") @@ -11,17 +21,20 @@ class UserAdmin(admin.ModelAdmin): search_fields = ["email", "last_name", "first_name"] readonly_fields = ("last_login", "date_joined") + + form = UserAdminForm fieldsets = ( ( _("User settings"), {"fields": ("email", "first_name", "last_name", "is_active")}, ), + ( + _("Reddit settings"), + {"fields": ("reddit_access_token", "reddit_refresh_token")}, + ), ( _("Permission settings"), - { - "classes": ("collapse",), - "fields": ("is_staff", "is_superuser", "groups", "user_permissions"), - }, + {"classes": ("collapse",), "fields": ("is_staff", "is_superuser")}, ), (_("Misc settings"), {"fields": ("date_joined", "last_login")}), ) diff --git a/src/newsreader/accounts/migrations/0010_auto_20200603_2230.py b/src/newsreader/accounts/migrations/0010_auto_20200603_2230.py new file mode 100644 index 0000000..294ff31 --- /dev/null +++ b/src/newsreader/accounts/migrations/0010_auto_20200603_2230.py @@ -0,0 +1,21 @@ +# Generated by Django 3.0.5 on 2020-06-03 20:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("accounts", "0009_auto_20200524_1218")] + + operations = [ + migrations.AddField( + model_name="user", + name="reddit_access_token", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="user", + name="reddit_refresh_token", + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/src/newsreader/accounts/models.py b/src/newsreader/accounts/models.py index 18eba07..b8aaa64 100644 --- a/src/newsreader/accounts/models.py +++ b/src/newsreader/accounts/models.py @@ -50,6 +50,9 @@ class User(AbstractUser): verbose_name="collection task", ) + reddit_refresh_token = models.CharField(max_length=255, blank=True, null=True) + reddit_access_token = models.CharField(max_length=255, blank=True, null=True) + username = None objects = UserManager() @@ -69,7 +72,7 @@ class User(AbstractUser): enabled=True, interval=task_interval, name=f"{self.email}-collection-task", - task="newsreader.news.collection.tasks.FeedTask", + task="FeedTask", args=json.dumps([self.pk]), ) diff --git a/src/newsreader/accounts/templates/accounts/components/settings-form.html b/src/newsreader/accounts/templates/accounts/components/settings-form.html index ff06cb7..7942354 100644 --- a/src/newsreader/accounts/templates/accounts/components/settings-form.html +++ b/src/newsreader/accounts/templates/accounts/components/settings-form.html @@ -13,6 +13,18 @@ {% include "components/form/confirm-button.html" %} + + {% if reddit_authorization_url %} + + {% trans "Authorize Reddit account" %} + + {% endif %} + + {% if reddit_refresh_url %} + + {% trans "Refresh Reddit access token" %} + + {% endif %} {% endblock actions %} diff --git a/src/newsreader/accounts/templates/accounts/views/reddit.html b/src/newsreader/accounts/templates/accounts/views/reddit.html new file mode 100644 index 0000000..b393bbe --- /dev/null +++ b/src/newsreader/accounts/templates/accounts/views/reddit.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% block content %} +
      +
      + {% if error %} +

      Reddit authorization failed

      +

      {{ error }}

      + {% elif access_token and refresh_token %} +

      Reddit account is linked

      +

      Your reddit account was successfully linked.

      + {% endif %} + +

      Return to settings page

      +
      +
      +{% endblock %} diff --git a/src/newsreader/accounts/tests/test_settings.py b/src/newsreader/accounts/tests/test_settings.py new file mode 100644 index 0000000..d093ea4 --- /dev/null +++ b/src/newsreader/accounts/tests/test_settings.py @@ -0,0 +1,161 @@ +from unittest.mock import patch +from urllib.parse import urlencode +from uuid import uuid4 + +from django.core.cache import cache +from django.test import TestCase +from django.urls import reverse + +from newsreader.accounts.models import User +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.exceptions import StreamTooManyException + + +class SettingsViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(email="test@test.nl", password="test") + self.client.force_login(self.user) + + self.url = reverse("accounts:settings") + + def test_simple(self): + response = self.client.get(self.url) + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Authorize Reddit account") + + def test_user_credential_change(self): + response = self.client.post( + reverse("accounts:settings"), + {"first_name": "First name", "last_name": "Last name"}, + ) + + user = User.objects.get() + + self.assertRedirects(response, reverse("accounts:settings")) + + self.assertEquals(user.first_name, "First name") + self.assertEquals(user.last_name, "Last name") + + def test_linked_reddit_account(self): + self.user.reddit_refresh_token = "test" + self.user.save() + + response = self.client.get(self.url) + + self.assertEquals(response.status_code, 200) + self.assertNotContains(response, "Authorize Reddit account") + + +class RedditTemplateViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(email="test@test.nl", password="test") + self.client.force_login(self.user) + + self.base_url = reverse("accounts:reddit-template") + self.state = str(uuid4()) + + self.patch = patch("newsreader.news.collection.reddit.post") + self.mocked_post = self.patch.start() + + def tearDown(self): + patch.stopall() + + def test_simple(self): + response = self.client.get(self.base_url) + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Return to settings page") + + def test_successful_authorization(self): + self.mocked_post.return_value.json.return_value = { + "access_token": "1001010412", + "refresh_token": "134510143", + } + + cache.set(f"{self.user.email}-reddit-auth", self.state) + + params = {"state": self.state, "code": "Valid code"} + url = f"{self.base_url}?{urlencode(params)}" + + response = self.client.get(url) + + self.mocked_post.assert_called_once() + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Your reddit account was successfully linked.") + + self.user.refresh_from_db() + + self.assertEquals(self.user.reddit_access_token, "1001010412") + self.assertEquals(self.user.reddit_refresh_token, "134510143") + + self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), None) + + def test_error(self): + params = {"error": "Denied authorization"} + + url = f"{self.base_url}?{urlencode(params)}" + + response = self.client.get(url) + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Denied authorization") + + def test_invalid_state(self): + cache.set(f"{self.user.email}-reddit-auth", str(uuid4())) + + params = {"code": "Valid code", "state": "Invalid state"} + + url = f"{self.base_url}?{urlencode(params)}" + + response = self.client.get(url) + + self.assertEquals(response.status_code, 200) + self.assertContains( + response, "The saved state for Reddit authorization did not match" + ) + + def test_stream_error(self): + self.mocked_post.side_effect = StreamTooManyException + + cache.set(f"{self.user.email}-reddit-auth", self.state) + + params = {"state": self.state, "code": "Valid code"} + url = f"{self.base_url}?{urlencode(params)}" + + response = self.client.get(url) + + self.mocked_post.assert_called_once() + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Too many requests") + + self.user.refresh_from_db() + + self.assertEquals(self.user.reddit_access_token, None) + self.assertEquals(self.user.reddit_refresh_token, None) + + self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), self.state) + + def test_unexpected_json(self): + self.mocked_post.return_value.json.return_value = {"message": "Happy eastern"} + + cache.set(f"{self.user.email}-reddit-auth", self.state) + + params = {"state": self.state, "code": "Valid code"} + url = f"{self.base_url}?{urlencode(params)}" + + response = self.client.get(url) + + self.mocked_post.assert_called_once() + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Access and refresh token not found in response") + + self.user.refresh_from_db() + + self.assertEquals(self.user.reddit_access_token, None) + self.assertEquals(self.user.reddit_refresh_token, None) + + self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), self.state) diff --git a/src/newsreader/accounts/tests/test_views.py b/src/newsreader/accounts/tests/test_views.py deleted file mode 100644 index d3ac77c..0000000 --- a/src/newsreader/accounts/tests/test_views.py +++ /dev/null @@ -1,29 +0,0 @@ -from django.test import TestCase -from django.urls import reverse - -from newsreader.accounts.models import User -from newsreader.accounts.tests.factories import UserFactory - - -class UserSettingsViewTestCase(TestCase): - def setUp(self): - self.user = UserFactory(password="test") - self.client.force_login(self.user) - - def test_simple(self): - response = self.client.get(reverse("accounts:settings")) - - self.assertEquals(response.status_code, 200) - - def test_user_credential_change(self): - response = self.client.post( - reverse("accounts:settings"), - {"first_name": "First name", "last_name": "Last name"}, - ) - - user = User.objects.get() - - self.assertRedirects(response, reverse("accounts:settings")) - - self.assertEquals(user.first_name, "First name") - self.assertEquals(user.last_name, "Last name") diff --git a/src/newsreader/accounts/urls.py b/src/newsreader/accounts/urls.py index d42ae13..672cf6d 100644 --- a/src/newsreader/accounts/urls.py +++ b/src/newsreader/accounts/urls.py @@ -12,6 +12,8 @@ from newsreader.accounts.views import ( PasswordResetConfirmView, PasswordResetDoneView, PasswordResetView, + RedditTemplateView, + RedditTokenRedirectView, RegistrationClosedView, RegistrationCompleteView, RegistrationView, @@ -61,4 +63,14 @@ urlpatterns = [ name="password-change", ), path("settings/", login_required(SettingsView.as_view()), name="settings"), + path( + "settings/reddit/callback/", + login_required(RedditTemplateView.as_view()), + name="reddit-template", + ), + path( + "settings/reddit/refresh/", + login_required(RedditTokenRedirectView.as_view()), + name="reddit-refresh", + ), ] diff --git a/src/newsreader/accounts/views.py b/src/newsreader/accounts/views.py index fed60eb..4f982a9 100644 --- a/src/newsreader/accounts/views.py +++ b/src/newsreader/accounts/views.py @@ -1,13 +1,22 @@ +from django.contrib import messages from django.contrib.auth import views as django_views +from django.core.cache import cache from django.shortcuts import render from django.urls import reverse_lazy -from django.views.generic import TemplateView +from django.utils.translation import gettext as _ +from django.views.generic import RedirectView, TemplateView from django.views.generic.edit import FormView, ModelFormMixin from registration.backends.default import views as registration_views from newsreader.accounts.forms import UserSettingsForm from newsreader.accounts.models import User +from newsreader.news.collection.exceptions import StreamException +from newsreader.news.collection.reddit import ( + get_reddit_access_token, + get_reddit_authorization_url, +) +from newsreader.news.collection.tasks import RedditTokenTask class LoginView(django_views.LoginView): @@ -111,5 +120,91 @@ class SettingsView(ModelFormMixin, FormView): def get_object(self, **kwargs): return self.request.user + def get_context_data(self, **kwargs): + user = self.request.user + + reddit_authorization_url = None + reddit_refresh_url = None + reddit_task_active = cache.get(f"{user.email}-reddit-refresh") + + if ( + user.reddit_refresh_token + and not user.reddit_access_token + and not reddit_task_active + ): + reddit_refresh_url = reverse_lazy("accounts:reddit-refresh") + + if not user.reddit_refresh_token: + reddit_authorization_url = get_reddit_authorization_url(user) + + return { + **super().get_context_data(**kwargs), + "reddit_authorization_url": reddit_authorization_url, + "reddit_refresh_url": reddit_refresh_url, + } + def get_form_kwargs(self): return {**super().get_form_kwargs(), "instance": self.request.user} + + +class RedditTemplateView(TemplateView): + template_name = "accounts/views/reddit.html" + + def get(self, request, *args, **kwargs): + context = self.get_context_data(**kwargs) + + error = request.GET.get("error", None) + state = request.GET.get("state", None) + code = request.GET.get("code", None) + + if error: + return self.render_to_response({**context, "error": error}) + + if not code or not state: + return self.render_to_response(context) + + cached_state = cache.get(f"{request.user.email}-reddit-auth") + + if state != cached_state: + return self.render_to_response( + { + **context, + "error": "The saved state for Reddit authorization did not match", + } + ) + + try: + access_token, refresh_token = get_reddit_access_token(code, request.user) + + return self.render_to_response( + { + **context, + "access_token": access_token, + "refresh_token": refresh_token, + } + ) + except StreamException as e: + return self.render_to_response({**context, "error": str(e)}) + except KeyError: + return self.render_to_response( + {**context, "error": "Access and refresh token not found in response"} + ) + + +class RedditTokenRedirectView(RedirectView): + url = reverse_lazy("accounts:settings") + + def get(self, request, *args, **kwargs): + response = super().get(request, *args, **kwargs) + + user = request.user + task_active = cache.get(f"{user.email}-reddit-refresh") + + if not task_active: + RedditTokenTask.delay(user.pk) + messages.success(request, _("Access token is being retrieved")) + cache.set(f"{user.email}-reddit-refresh", 1, 300) + return response + + messages.error(request, _("Unable to retrieve token")) + return response diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 62008a3..b117b4f 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -11,8 +11,8 @@ DJANGO_PROJECT_DIR = os.path.join(BASE_DIR, "src", "newsreader") # SECURITY WARNING: don"t run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = ["127.0.0.1"] -INTERNAL_IPS = ["127.0.0.1"] +ALLOWED_HOSTS = ["127.0.0.1", "localhost"] +INTERNAL_IPS = ["127.0.0.1", "localhost"] # Application definition INSTALLED_APPS = [ @@ -162,7 +162,13 @@ LOGGING = { "level": "INFO", "propagate": False, }, - "celery.task": {"handlers": ["syslog", "console"], "level": "INFO"}, + "celery": {"handlers": ["syslog", "console"], "level": "INFO"}, + "celery.task": { + "handlers": ["syslog", "console"], + "level": "INFO", + "propagate": False, + }, + "newsreader": {"handlers": ["syslog", "console"], "level": "INFO"}, }, } @@ -205,6 +211,12 @@ STATICFILES_FINDERS = [ DEFAULT_FROM_EMAIL = "newsreader@rss.fudiggity.nl" +# Project settings +# Reddit integration +REDDIT_CLIENT_ID = "CLIENT_ID" +REDDIT_CLIENT_SECRET = "CLIENT_SECRET" +REDDIT_REDIRECT_URL = "http://127.0.0.1:8000/accounts/settings/reddit/callback/" + # Third party settings AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler" AXES_CACHE = "axes" diff --git a/src/newsreader/conf/production.py b/src/newsreader/conf/production.py index 4c2c480..0dee323 100644 --- a/src/newsreader/conf/production.py +++ b/src/newsreader/conf/production.py @@ -45,6 +45,11 @@ TEMPLATES = [ } ] +# Reddit integration +REDDIT_CLIENT_ID = os.environ["REDDIT_CLIENT_ID"] +REDDIT_CLIENT_SECRET = os.environ["REDDIT_CLIENT_SECRET"] +REDDIT_REDIRECT_URL = "https://rss.fudiggity.nl/settings/reddit/callback/" + # Third party settings AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler" diff --git a/src/newsreader/js/components/Card.js b/src/newsreader/js/components/Card.js index d1580a4..6346dcb 100644 --- a/src/newsreader/js/components/Card.js +++ b/src/newsreader/js/components/Card.js @@ -2,7 +2,7 @@ import React from 'react'; const Card = props => { return ( -
      +
      {props.header}
      {props.content}
      {props.footer}
      diff --git a/src/newsreader/news/collection/base.py b/src/newsreader/news/collection/base.py index 710580f..f980191 100644 --- a/src/newsreader/news/collection/base.py +++ b/src/newsreader/news/collection/base.py @@ -1,18 +1,23 @@ from bs4 import BeautifulSoup from newsreader.news.collection.exceptions import StreamParseException -from newsreader.news.collection.models import CollectionRule from newsreader.news.collection.utils import fetch class Stream: + """ + Contains the data and makes it available for processing + """ + + rule = None + def __init__(self, rule): self.rule = rule def read(self): raise NotImplementedError - def parse(self, payload): + def parse(self, response): raise NotImplementedError class Meta: @@ -20,9 +25,13 @@ class Stream: class Client: + """ + Retrieves the data with streams + """ + stream = Stream - def __init__(self, rules=None): + def __init__(self, rules=[]): self.rules = rules if rules else CollectionRule.objects.enabled() def __enter__(self): @@ -39,7 +48,12 @@ class Client: class Builder: + """ + Creates the collected posts + """ + instances = [] + stream = None def __init__(self, stream): self.stream = stream @@ -62,6 +76,10 @@ class Builder: class Collector: + """ + Glue between client, streams and builder + """ + client = None builder = None diff --git a/src/newsreader/news/collection/choices.py b/src/newsreader/news/collection/choices.py new file mode 100644 index 0000000..65f7ef5 --- /dev/null +++ b/src/newsreader/news/collection/choices.py @@ -0,0 +1,7 @@ +from django.db.models import TextChoices +from django.utils.translation import gettext as _ + + +class RuleTypeChoices(TextChoices): + feed = "feed", _("Feed") + subreddit = "subreddit", _("Subreddit") diff --git a/src/newsreader/news/collection/exceptions.py b/src/newsreader/news/collection/exceptions.py index e636638..e002b43 100644 --- a/src/newsreader/news/collection/exceptions.py +++ b/src/newsreader/news/collection/exceptions.py @@ -1,7 +1,8 @@ class StreamException(Exception): message = "Stream exception" - def __init__(self, message=None): + def __init__(self, response=None, message=None): + self.response = response self.message = message if message else self.message def __str__(self): @@ -28,5 +29,9 @@ class StreamParseException(StreamException): message = "Stream could not be parsed" -class StreamConnectionError(StreamException): +class StreamConnectionException(StreamException): message = "A connection to the stream could not be made" + + +class StreamTooManyException(StreamException): + message = "Too many requests" diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index e237713..8018bb5 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -4,8 +4,6 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import timedelta from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist -from django.db.models.fields import CharField, TextField -from django.template.defaultfilters import truncatechars from django.utils import timezone import bleach @@ -14,6 +12,7 @@ import pytz from feedparser import parse from newsreader.news.collection.base import Builder, Client, Collector, Stream +from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.constants import ( WHITELISTED_ATTRIBUTES, WHITELISTED_TAGS, @@ -25,7 +24,12 @@ from newsreader.news.collection.exceptions import ( StreamParseException, StreamTimeOutException, ) -from newsreader.news.collection.utils import build_publication_date, fetch +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.utils import ( + build_publication_date, + fetch, + truncate_text, +) from newsreader.news.core.models import Post @@ -37,10 +41,13 @@ class FeedBuilder(Builder): def __enter__(self): _, stream = self.stream + self.instances = [] self.existing_posts = { post.remote_identifier: post - for post in Post.objects.filter(rule=stream.rule) + for post in Post.objects.filter( + rule=stream.rule, rule__type=RuleTypeChoices.feed + ) } return super().__enter__() @@ -73,7 +80,7 @@ class FeedBuilder(Builder): if not field in entry: continue - value = self.truncate_text(model_field, entry[field]) + value = truncate_text(Post, model_field, entry[field]) if field == "published_parsed": data[model_field] = build_publication_date(value, tz) @@ -103,21 +110,6 @@ class FeedBuilder(Builder): strip_comments=True, ) - def truncate_text(self, field_name, value): - field = Post._meta.get_field(field_name) - max_length = field.max_length - cls = type(field) - - if not value or not max_length: - return value - elif not bool(issubclass(cls, CharField) or issubclass(cls, TextField)): - return value - - if len(value) > max_length: - return truncatechars(value, max_length) - - return value - def get_content(self, items): content = "\n ".join([item.get("value") for item in items]) return self.sanitize_fragment(content) @@ -129,21 +121,29 @@ class FeedBuilder(Builder): class FeedStream(Stream): def read(self): - url = self.rule.url - response = fetch(url) + response = fetch(self.rule.url) - return (self.parse(response.content), self) + return self.parse(response), self - def parse(self, payload): + def parse(self, response): try: - return parse(payload) + return parse(response.content) except TypeError as e: - raise StreamParseException("Could not parse feed") from e + message = "Could not parse feed" + raise StreamParseException(response=response, message=message) from e class FeedClient(Client): stream = FeedStream + def __init__(self, rules=[]): + if rules: + self.rules = rules + else: + self.rules = CollectionRule.objects.filter( + enabled=True, type=RuleTypeChoices.feed + ) + def __enter__(self): streams = [self.stream(rule) for rule in self.rules] diff --git a/src/newsreader/news/collection/forms.py b/src/newsreader/news/collection/forms.py index 7e5fc97..1d9b996 100644 --- a/src/newsreader/news/collection/forms.py +++ b/src/newsreader/news/collection/forms.py @@ -1,18 +1,29 @@ from django import forms +from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ import pytz +from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.models import CollectionRule from newsreader.news.core.models import Category +def get_reddit_help_text(): + return mark_safe( + "Only subreddits are supported. For example: " + "https://www.reddit.com/r/aww" + ) + + class CollectionRuleForm(forms.ModelForm): category = forms.ModelChoiceField(required=False, queryset=Category.objects.all()) timezone = forms.ChoiceField( widget=forms.Select(attrs={"size": len(pytz.all_timezones)}), choices=((timezone, timezone) for timezone in pytz.all_timezones), help_text=_("The timezone which the feed uses"), + initial=pytz.utc, ) def __init__(self, *args, **kwargs): @@ -20,8 +31,7 @@ class CollectionRuleForm(forms.ModelForm): super().__init__(*args, **kwargs) - if self.user: - self.fields["category"].queryset = Category.objects.filter(user=self.user) + self.fields["category"].queryset = Category.objects.filter(user=self.user) def save(self, commit=True): instance = super().save(commit=False) @@ -49,6 +59,32 @@ class CollectionRuleBulkForm(forms.Form): self.fields["rules"].queryset = CollectionRule.objects.filter(user=user) +class SubRedditRuleForm(CollectionRuleForm): + url = forms.URLField(max_length=1024, help_text=get_reddit_help_text) + + timezone = None + + def save(self, commit=True): + instance = super().save(commit=False) + + instance.type = RuleTypeChoices.subreddit + instance.timezone = str(pytz.utc) + instance.user = self.user + + if not instance.url.endswith(".json"): + instance.url = f"{instance.url}.json" + + if commit: + instance.save() + self.save_m2m() + + return instance + + class Meta: + model = CollectionRule + fields = ("name", "url", "favicon", "category") + + class OPMLImportForm(forms.Form): file = forms.FileField(allow_empty_file=False) skip_existing = forms.BooleanField(initial=False, required=False) diff --git a/src/newsreader/news/collection/migrations/0008_collectionrule_type.py b/src/newsreader/news/collection/migrations/0008_collectionrule_type.py new file mode 100644 index 0000000..bb8975d --- /dev/null +++ b/src/newsreader/news/collection/migrations/0008_collectionrule_type.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.5 on 2020-06-03 20:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("collection", "0007_collectionrule_enabled")] + + operations = [ + migrations.AddField( + model_name="collectionrule", + name="type", + field=models.CharField( + choices=[("feed", "Feed"), ("subreddit", "Subreddit")], + default="feed", + max_length=20, + ), + ) + ] diff --git a/src/newsreader/news/collection/models.py b/src/newsreader/news/collection/models.py index cc22f8a..35841ba 100644 --- a/src/newsreader/news/collection/models.py +++ b/src/newsreader/news/collection/models.py @@ -1,9 +1,11 @@ from django.db import models +from django.urls import reverse from django.utils.translation import gettext as _ import pytz from newsreader.core.models import TimeStampedModel +from newsreader.news.collection.choices import RuleTypeChoices class CollectionRuleQuerySet(models.QuerySet): @@ -13,6 +15,9 @@ class CollectionRuleQuerySet(models.QuerySet): class CollectionRule(TimeStampedModel): name = models.CharField(max_length=100) + type = models.CharField( + max_length=20, choices=RuleTypeChoices.choices, default=RuleTypeChoices.feed + ) url = models.URLField(max_length=1024) website_url = models.URLField( @@ -23,7 +28,7 @@ class CollectionRule(TimeStampedModel): timezone = models.CharField( choices=((timezone, timezone) for timezone in pytz.all_timezones), max_length=100, - default="UTC", + default=str(pytz.utc), ) category = models.ForeignKey( @@ -38,7 +43,9 @@ class CollectionRule(TimeStampedModel): last_suceeded = models.DateTimeField(blank=True, null=True) succeeded = models.BooleanField(default=False) + error = models.CharField(max_length=1024, blank=True, null=True) + enabled = models.BooleanField( default=True, help_text=_("Wether or not to collect items from this feed") ) @@ -54,3 +61,10 @@ class CollectionRule(TimeStampedModel): def __str__(self): return self.name + + @property + def update_url(self): + if self.type == RuleTypeChoices.subreddit: + return reverse("news:collection:subreddit-update", kwargs={"pk": self.pk}) + + return reverse("news:collection:rule-update", kwargs={"pk": self.pk}) diff --git a/src/newsreader/news/collection/reddit.py b/src/newsreader/news/collection/reddit.py new file mode 100644 index 0000000..2bb7bd9 --- /dev/null +++ b/src/newsreader/news/collection/reddit.py @@ -0,0 +1,307 @@ +import logging + +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timedelta +from html import unescape +from json.decoder import JSONDecodeError +from urllib.parse import urlencode +from uuid import uuid4 + +from django.conf import settings +from django.core.cache import cache +from django.utils import timezone + +import bleach +import pytz +import requests + +from newsreader.news.collection.base import Builder, Client, Collector, Stream +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.constants import ( + WHITELISTED_ATTRIBUTES, + WHITELISTED_TAGS, +) +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamException, + StreamParseException, + StreamTooManyException, +) +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.tasks import RedditTokenTask +from newsreader.news.collection.utils import fetch, post, truncate_text +from newsreader.news.core.models import Post + + +logger = logging.getLogger(__name__) + + +REDDIT_URL = "https://www.reddit.com" +REDDIT_API_URL = "https://oauth.reddit.com" + +RATE_LIMIT = 60 +RATE_LIMIT_DURATION = timedelta(seconds=60) + + +def get_reddit_authorization_url(user): + state = str(uuid4()) + cache.set(f"{user.email}-reddit-auth", state) + + params = { + "client_id": settings.REDDIT_CLIENT_ID, + "redirect_uri": settings.REDDIT_REDIRECT_URL, + "state": state, + "response_type": "code", + "duration": "permanent", + "scope": "identity,mysubreddits,save,read", + } + + authorization_url = f"{REDDIT_URL}/api/v1/authorize" + return f"{authorization_url}?{urlencode(params)}" + + +def get_reddit_access_token(code, user): + client_auth = requests.auth.HTTPBasicAuth( + settings.REDDIT_CLIENT_ID, settings.REDDIT_CLIENT_SECRET + ) + + response = post( + f"{REDDIT_URL}/api/v1/access_token", + data={ + "redirect_uri": settings.REDDIT_REDIRECT_URL, + "grant_type": "authorization_code", + "code": code, + }, + auth=client_auth, + ) + + response_data = response.json() + + user.reddit_access_token = response_data["access_token"] + user.reddit_refresh_token = response_data["refresh_token"] + user.save() + + cache.delete(f"{user.email}-reddit-auth") + + return response_data["access_token"], response_data["refresh_token"] + + +class RedditBuilder(Builder): + def __enter__(self): + _, stream = self.stream + + self.instances = [] + self.existing_posts = { + post.remote_identifier: post + for post in Post.objects.filter( + rule=stream.rule, rule__type=RuleTypeChoices.subreddit + ) + } + + return super().__enter__() + + def create_posts(self, stream): + data, stream = stream + posts = [] + + if not "data" in data or not "children" in data["data"]: + return + + posts = data["data"]["children"] + self.instances = self.build(posts, stream.rule) + + def build(self, posts, rule): + for post in posts: + if not "data" in post: + continue + + remote_identifier = post["data"]["id"] + title = truncate_text(Post, "title", post["data"]["title"]) + author = truncate_text(Post, "author", post["data"]["author"]) + url_fragment = f"{post['data']['permalink']}" + + uncleaned_body = post["data"]["selftext_html"] + unescaped_body = unescape(uncleaned_body) if uncleaned_body else "" + body = ( + bleach.clean( + unescaped_body, + tags=WHITELISTED_TAGS, + attributes=WHITELISTED_ATTRIBUTES, + strip=True, + strip_comments=True, + ) + if unescaped_body + else "" + ) + + try: + parsed_date = datetime.fromtimestamp(post["data"]["created_utc"]) + created_date = pytz.utc.localize(parsed_date) + except (OverflowError, OSError): + logging.warning(f"Failed parsing timestamp from {url_fragment}") + created_date = timezone.now() + + data = { + "remote_identifier": remote_identifier, + "title": title, + "body": body, + "author": author, + "url": f"{REDDIT_URL}{url_fragment}", + "publication_date": created_date, + "rule": rule, + } + + if remote_identifier in self.existing_posts: + existing_post = self.existing_posts[remote_identifier] + + if created_date > existing_post.publication_date: + for key, value in data.items(): + setattr(existing_post, key, value) + + yield existing_post + continue + + yield Post(**data) + + def save(self): + for post in self.instances: + post.save() + + +class RedditScheduler: + max_amount = RATE_LIMIT + max_user_amount = RATE_LIMIT / 4 + + def __init__(self, subreddits=[]): + if not subreddits: + self.subreddits = CollectionRule.objects.filter( + type=RuleTypeChoices.subreddit, + user__reddit_access_token__isnull=False, + user__reddit_refresh_token__isnull=False, + enabled=True, + ).order_by("last_suceeded")[:200] + else: + self.subreddits = subreddits + + def get_scheduled_rules(self): + rule_mapping = {} + current_amount = 0 + + for subreddit in self.subreddits: + user_pk = subreddit.user.pk + + if current_amount == self.max_amount: + break + + if user_pk in rule_mapping: + max_amount_reached = len(rule_mapping[user_pk]) == self.max_user_amount + + if max_amount_reached: + continue + + rule_mapping[user_pk].append(subreddit) + current_amount += 1 + + continue + + rule_mapping[user_pk] = [subreddit] + current_amount += 1 + + return list(rule_mapping.values()) + + +class RedditStream(Stream): + headers = {} + user = None + + def __init__(self, rule): + super().__init__(rule) + + self.user = self.rule.user + self.headers = { + f"Authorization": f"bearer {self.rule.user.reddit_access_token}" + } + + def read(self): + response = fetch(self.rule.url, headers=self.headers) + + return self.parse(response), self + + def parse(self, response): + try: + return response.json() + except JSONDecodeError as e: + raise StreamParseException( + response=response, message=f"Failed parsing json" + ) from e + + +class RedditClient(Client): + stream = RedditStream + + def __init__(self, rules=[]): + self.rules = rules + + def __enter__(self): + streams = [[self.stream(rule) for rule in batch] for batch in self.rules] + rate_limitted = False + + with ThreadPoolExecutor(max_workers=10) as executor: + for batch in streams: + futures = {executor.submit(stream.read): stream for stream in batch} + + if rate_limitted: + break + + for future in as_completed(futures): + stream = futures[future] + + try: + response_data = future.result() + + stream.rule.error = None + stream.rule.succeeded = True + stream.rule.last_suceeded = timezone.now() + + yield response_data + except StreamDeniedException as e: + logger.exception( + f"Access token expired for user {stream.user.pk}" + ) + + stream.rule.user.reddit_access_token = None + stream.rule.user.save() + + self.set_rule_error(stream.rule, e) + + RedditTokenTask.delay(stream.rule.user.pk) + + break + except StreamTooManyException as e: + logger.exception("Ratelimit hit, aborting batched subreddits") + + self.set_rule_error(stream.rule, e) + + rate_limitted = True + break + except StreamException as e: + logger.exception( + "Stream failed reading content from " f"{stream.rule.url}" + ) + + self.set_rule_error(stream.rule, e) + + continue + finally: + stream.rule.save() + + def set_rule_error(self, rule, exception): + length = rule._meta.get_field("error").max_length + + rule.error = exception.message[-length:] + rule.succeeded = False + + +class RedditCollector(Collector): + builder = RedditBuilder + client = RedditClient diff --git a/src/newsreader/news/collection/response_handler.py b/src/newsreader/news/collection/response_handler.py index 3a16376..2cd785d 100644 --- a/src/newsreader/news/collection/response_handler.py +++ b/src/newsreader/news/collection/response_handler.py @@ -1,12 +1,13 @@ from requests.exceptions import ConnectionError as RequestConnectionError from newsreader.news.collection.exceptions import ( - StreamConnectionError, + StreamConnectionException, StreamDeniedException, StreamException, StreamForbiddenException, StreamNotFoundException, StreamTimeOutException, + StreamTooManyException, ) @@ -16,9 +17,10 @@ class ResponseHandler: 401: StreamDeniedException, 403: StreamForbiddenException, 408: StreamTimeOutException, + 429: StreamTooManyException, } - exception_mapping = {RequestConnectionError: StreamConnectionError} + exception_mapping = {RequestConnectionError: StreamConnectionException} def __enter__(self): return self @@ -27,16 +29,20 @@ class ResponseHandler: status_code = response.status_code if status_code in self.status_code_mapping: - raise self.status_code_mapping[status_code] + exception = self.status_code_mapping[status_code] + raise exception(response) + + def map_exception(self, exception): + if isinstance(exception, StreamException): + raise exception - def handle_exception(self, exception): try: stream_exception = self.exception_mapping[type(exception)] except KeyError: stream_exception = StreamException message = getattr(exception, "message", str(exception)) - raise stream_exception(message=message) from exception + raise stream_exception(exception.response, message=message) from exception def __exit__(self, *args, **kwargs): pass diff --git a/src/newsreader/news/collection/tasks.py b/src/newsreader/news/collection/tasks.py index dab94d4..d368a5c 100644 --- a/src/newsreader/news/collection/tasks.py +++ b/src/newsreader/news/collection/tasks.py @@ -1,11 +1,15 @@ +from django.conf import settings from django.core.exceptions import ObjectDoesNotExist +import requests + from celery.exceptions import Reject from celery.utils.log import get_task_logger from newsreader.accounts.models import User from newsreader.celery import app from newsreader.news.collection.feed import FeedCollector +from newsreader.news.collection.utils import post from newsreader.utils.celery import MemCacheLock @@ -13,7 +17,7 @@ logger = get_task_logger(__name__) class FeedTask(app.Task): - name = "newsreader.news.collection.tasks.FeedTask" + name = "FeedTask" ignore_result = True def run(self, user_pk): @@ -41,4 +45,74 @@ class FeedTask(app.Task): raise Reject(reason="Task already running", requeue=False) +class RedditTask(app.Task): + name = "RedditTask" + ignore_result = True + + def run(self): + from newsreader.news.collection.reddit import RedditCollector, RedditScheduler + + with MemCacheLock("reddit-task", self.app.oid) as acquired: + if acquired: + logger.info(f"Running reddit task") + + scheduler = RedditScheduler() + subreddits = scheduler.get_scheduled_rules() + + collector = RedditCollector() + collector.collect(rules=subreddits) + else: + logger.warning(f"Cancelling task due to existing lock") + + raise Reject(reason="Task already running", requeue=False) + + +class RedditTokenTask(app.Task): + name = "RedditTokenTask" + ignore_result = True + + def run(self, user_pk): + from newsreader.news.collection.reddit import REDDIT_URL + + try: + user = User.objects.get(pk=user_pk) + except ObjectDoesNotExist: + message = f"User {user_pk} does not exist" + logger.exception(message) + + raise Reject(reason=message, requeue=False) + + if not user.reddit_refresh_token: + raise Reject(reason=f"User {user_pk} has no refresh token", requeue=False) + + client_auth = requests.auth.HTTPBasicAuth( + settings.REDDIT_CLIENT_ID, settings.REDDIT_CLIENT_SECRET + ) + + try: + response = post( + f"{REDDIT_URL}/api/v1/access_token", + data={ + "grant_type": "refresh_token", + "refresh_token": user.reddit_refresh_token, + }, + auth=client_auth, + ) + except StreamException: + logger.exception( + f"Failed refreshing reddit access token for user {user_pk}" + ) + + user.reddit_refresh_token = None + user.save() + return + + response_data = response.json() + + user.reddit_access_token = response_data["access_token"] + user.save() + + FeedTask = app.register_task(FeedTask()) +RedditTask = app.register_task(RedditTask()) +RedditTokenTask = app.register_task(RedditTokenTask()) diff --git a/src/newsreader/news/collection/templates/news/collection/views/rules.html b/src/newsreader/news/collection/templates/news/collection/views/rules.html index 1da7c4d..b8ab514 100644 --- a/src/newsreader/news/collection/templates/news/collection/views/rules.html +++ b/src/newsreader/news/collection/templates/news/collection/views/rules.html @@ -15,6 +15,7 @@ @@ -48,7 +49,7 @@ {{ rule.succeeded }} {{ rule.enabled }} - + {% endfor %} diff --git a/src/newsreader/news/collection/templates/news/collection/views/subreddit-create.html b/src/newsreader/news/collection/templates/news/collection/views/subreddit-create.html new file mode 100644 index 0000000..6250e4e --- /dev/null +++ b/src/newsreader/news/collection/templates/news/collection/views/subreddit-create.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
      + {% url "news:collection:rules" as cancel_url %} + {% include "components/form/form.html" with form=form title="Add a subreddit" cancel_url=cancel_url confirm_text="Add subrredit" %} +
      +{% endblock %} diff --git a/src/newsreader/news/collection/templates/news/collection/views/subreddit-update.html b/src/newsreader/news/collection/templates/news/collection/views/subreddit-update.html new file mode 100644 index 0000000..9ea7d05 --- /dev/null +++ b/src/newsreader/news/collection/templates/news/collection/views/subreddit-update.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
      + {% url "news:collection:rules" as cancel_url %} + {% include "components/form/form.html" with form=form title="Update subreddit" cancel_url=cancel_url confirm_text="Save subreddit" %} +
      +{% endblock %} diff --git a/src/newsreader/news/collection/tests/factories.py b/src/newsreader/news/collection/tests/factories.py index 678e0f4..fdf786f 100644 --- a/src/newsreader/news/collection/tests/factories.py +++ b/src/newsreader/news/collection/tests/factories.py @@ -1,7 +1,9 @@ import factory from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.reddit import REDDIT_URL class CollectionRuleFactory(factory.django.DjangoModelFactory): @@ -17,3 +19,12 @@ class CollectionRuleFactory(factory.django.DjangoModelFactory): class Meta: model = CollectionRule + + +class FeedFactory(CollectionRuleFactory): + type = RuleTypeChoices.feed + + +class SubredditFactory(CollectionRuleFactory): + type = RuleTypeChoices.subreddit + website_url = REDDIT_URL diff --git a/src/newsreader/news/collection/tests/feed/builder/tests.py b/src/newsreader/news/collection/tests/feed/builder/tests.py index cfafa4f..7069f96 100644 --- a/src/newsreader/news/collection/tests/feed/builder/tests.py +++ b/src/newsreader/news/collection/tests/feed/builder/tests.py @@ -9,7 +9,7 @@ import pytz from freezegun import freeze_time from newsreader.news.collection.feed import FeedBuilder -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory from newsreader.news.core.models import Post from newsreader.news.core.tests.factories import PostFactory @@ -23,7 +23,7 @@ class FeedBuilderTestCase(TestCase): def test_basic_entry(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((simple_mock, mock_stream)) as builder: @@ -54,7 +54,7 @@ class FeedBuilderTestCase(TestCase): def test_multiple_entries(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((multiple_mock, mock_stream)) as builder: @@ -115,7 +115,7 @@ class FeedBuilderTestCase(TestCase): def test_entries_without_remote_identifier(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_without_identifier, mock_stream)) as builder: @@ -154,7 +154,7 @@ class FeedBuilderTestCase(TestCase): def test_entry_without_publication_date(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_without_publish_date, mock_stream)) as builder: @@ -186,7 +186,7 @@ class FeedBuilderTestCase(TestCase): def test_entry_without_url(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_without_url, mock_stream)) as builder: @@ -212,7 +212,7 @@ class FeedBuilderTestCase(TestCase): def test_entry_without_body(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_without_body, mock_stream)) as builder: @@ -246,7 +246,7 @@ class FeedBuilderTestCase(TestCase): def test_entry_without_author(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_without_author, mock_stream)) as builder: @@ -274,7 +274,7 @@ class FeedBuilderTestCase(TestCase): def test_empty_entries(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_without_entries, mock_stream)) as builder: @@ -284,7 +284,7 @@ class FeedBuilderTestCase(TestCase): def test_update_entries(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) existing_first_post = PostFactory.create( @@ -314,7 +314,7 @@ class FeedBuilderTestCase(TestCase): def test_html_sanitizing(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_with_html, mock_stream)) as builder: @@ -336,7 +336,7 @@ class FeedBuilderTestCase(TestCase): def test_long_author_text_is_truncated(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_with_long_author, mock_stream)) as builder: @@ -350,7 +350,7 @@ class FeedBuilderTestCase(TestCase): def test_long_title_text_is_truncated(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_with_long_title, mock_stream)) as builder: @@ -364,7 +364,7 @@ class FeedBuilderTestCase(TestCase): def test_content_detail_is_prioritized_if_longer(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_with_longer_content_detail, mock_stream)) as builder: @@ -381,7 +381,7 @@ class FeedBuilderTestCase(TestCase): def test_content_detail_is_not_prioritized_if_shorter(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_with_shorter_content_detail, mock_stream)) as builder: @@ -397,7 +397,7 @@ class FeedBuilderTestCase(TestCase): def test_content_detail_is_concatinated(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_with_multiple_content_detail, mock_stream)) as builder: diff --git a/src/newsreader/news/collection/tests/feed/client/tests.py b/src/newsreader/news/collection/tests/feed/client/tests.py index dd3c1e4..59b5f65 100644 --- a/src/newsreader/news/collection/tests/feed/client/tests.py +++ b/src/newsreader/news/collection/tests/feed/client/tests.py @@ -11,7 +11,7 @@ from newsreader.news.collection.exceptions import ( StreamTimeOutException, ) from newsreader.news.collection.feed import FeedClient -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory from .mocks import simple_mock @@ -27,8 +27,9 @@ class FeedClientTestCase(TestCase): patch.stopall() def test_client_retrieves_single_rules(self): - rule = CollectionRuleFactory.create() + rule = FeedFactory.create() mock_stream = MagicMock(rule=rule) + self.mocked_read.return_value = (simple_mock, mock_stream) with FeedClient([rule]) as client: @@ -39,9 +40,10 @@ class FeedClientTestCase(TestCase): self.mocked_read.assert_called_once_with() def test_client_catches_stream_exception(self): - rule = CollectionRuleFactory.create() + rule = FeedFactory.create() mock_stream = MagicMock(rule=rule) - self.mocked_read.side_effect = StreamException("Stream exception") + + self.mocked_read.side_effect = StreamException(message="Stream exception") with FeedClient([rule]) as client: for data, stream in client: @@ -52,9 +54,12 @@ class FeedClientTestCase(TestCase): self.mocked_read.assert_called_once_with() def test_client_catches_stream_not_found_exception(self): - rule = CollectionRuleFactory.create() + rule = FeedFactory.create() mock_stream = MagicMock(rule=rule) - self.mocked_read.side_effect = StreamNotFoundException("Stream not found") + + self.mocked_read.side_effect = StreamNotFoundException( + message="Stream not found" + ) with FeedClient([rule]) as client: for data, stream in client: @@ -65,9 +70,10 @@ class FeedClientTestCase(TestCase): self.mocked_read.assert_called_once_with() def test_client_catches_stream_denied_exception(self): - rule = CollectionRuleFactory.create() + rule = FeedFactory.create() mock_stream = MagicMock(rule=rule) - self.mocked_read.side_effect = StreamDeniedException("Stream denied") + + self.mocked_read.side_effect = StreamDeniedException(message="Stream denied") with FeedClient([rule]) as client: for data, stream in client: @@ -78,9 +84,12 @@ class FeedClientTestCase(TestCase): self.mocked_read.assert_called_once_with() def test_client_catches_stream_timed_out(self): - rule = CollectionRuleFactory.create() + rule = FeedFactory.create() mock_stream = MagicMock(rule=rule) - self.mocked_read.side_effect = StreamTimeOutException("Stream timed out") + + self.mocked_read.side_effect = StreamTimeOutException( + message="Stream timed out" + ) with FeedClient([rule]) as client: for data, stream in client: @@ -91,22 +100,12 @@ class FeedClientTestCase(TestCase): self.mocked_read.assert_called_once_with() def test_client_catches_stream_parse_exception(self): - rule = CollectionRuleFactory.create() + rule = FeedFactory.create() mock_stream = MagicMock(rule=rule) - self.mocked_read.side_effect = StreamParseException("Stream has wrong contents") - with FeedClient([rule]) as client: - for data, stream in client: - self.assertEquals(data, {"entries": []}) - self.assertEquals(stream.rule.error, "Stream has wrong contents") - self.assertEquals(stream.rule.succeeded, False) - - self.mocked_read.assert_called_once_with() - - def test_client_catches_stream_parse_exception(self): - rule = CollectionRuleFactory.create() - mock_stream = MagicMock(rule=rule) - self.mocked_read.side_effect = StreamParseException("Stream has wrong contents") + self.mocked_read.side_effect = StreamParseException( + message="Stream has wrong contents" + ) with FeedClient([rule]) as client: for data, stream in client: @@ -117,9 +116,10 @@ class FeedClientTestCase(TestCase): self.mocked_read.assert_called_once_with() def test_client_catches_long_exception_text(self): - rule = CollectionRuleFactory.create() + rule = FeedFactory.create() mock_stream = MagicMock(rule=rule) - self.mocked_read.side_effect = StreamParseException(words(1000)) + + self.mocked_read.side_effect = StreamParseException(message=words(1000)) with FeedClient([rule]) as client: for data, stream in client: diff --git a/src/newsreader/news/collection/tests/feed/collector/tests.py b/src/newsreader/news/collection/tests/feed/collector/tests.py index 0506783..b0fc7cf 100644 --- a/src/newsreader/news/collection/tests/feed/collector/tests.py +++ b/src/newsreader/news/collection/tests/feed/collector/tests.py @@ -18,7 +18,7 @@ from newsreader.news.collection.exceptions import ( StreamTimeOutException, ) from newsreader.news.collection.feed import FeedCollector -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory from newsreader.news.collection.utils import build_publication_date from newsreader.news.core.models import Post from newsreader.news.core.tests.factories import PostFactory @@ -42,7 +42,7 @@ class FeedCollectorTestCase(TestCase): @freeze_time("2019-10-30 12:30:00") def test_simple_batch(self): self.mocked_parse.return_value = multiple_mock - rule = CollectionRuleFactory() + rule = FeedFactory() collector = FeedCollector() collector.collect() @@ -58,7 +58,7 @@ class FeedCollectorTestCase(TestCase): def test_emtpy_batch(self): self.mocked_fetch.return_value = MagicMock() self.mocked_parse.return_value = empty_mock - rule = CollectionRuleFactory() + rule = FeedFactory() collector = FeedCollector() collector.collect() @@ -72,7 +72,7 @@ class FeedCollectorTestCase(TestCase): def test_not_found(self): self.mocked_fetch.side_effect = StreamNotFoundException - rule = CollectionRuleFactory() + rule = FeedFactory() collector = FeedCollector() collector.collect() @@ -88,7 +88,7 @@ class FeedCollectorTestCase(TestCase): last_suceeded = timezone.make_aware( datetime.combine(date=date(2019, 10, 30), time=time(12, 30)) ) - rule = CollectionRuleFactory(last_suceeded=last_suceeded) + rule = FeedFactory(last_suceeded=last_suceeded) collector = FeedCollector() collector.collect() @@ -105,7 +105,7 @@ class FeedCollectorTestCase(TestCase): last_suceeded = timezone.make_aware( datetime.combine(date=date(2019, 10, 30), time=time(12, 30)) ) - rule = CollectionRuleFactory(last_suceeded=last_suceeded) + rule = FeedFactory(last_suceeded=last_suceeded) collector = FeedCollector() collector.collect() @@ -122,7 +122,7 @@ class FeedCollectorTestCase(TestCase): last_suceeded = timezone.make_aware( datetime.combine(date=date(2019, 10, 30), time=time(12, 30)) ) - rule = CollectionRuleFactory(last_suceeded=last_suceeded) + rule = FeedFactory(last_suceeded=last_suceeded) collector = FeedCollector() collector.collect() @@ -137,7 +137,7 @@ class FeedCollectorTestCase(TestCase): @freeze_time("2019-10-30 12:30:00") def test_duplicates(self): self.mocked_parse.return_value = duplicate_mock - rule = CollectionRuleFactory() + rule = FeedFactory() aware_datetime = build_publication_date( struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), pytz.utc @@ -192,7 +192,7 @@ class FeedCollectorTestCase(TestCase): @freeze_time("2019-02-22 12:30:00") def test_items_with_identifiers_get_updated(self): self.mocked_parse.return_value = multiple_update_mock - rule = CollectionRuleFactory() + rule = FeedFactory() first_post = PostFactory( remote_identifier="https://www.bbc.co.uk/news/world-us-canada-48338168", @@ -248,10 +248,7 @@ class FeedCollectorTestCase(TestCase): @freeze_time("2019-02-22 12:30:00") def test_disabled_rules(self): - rules = ( - CollectionRuleFactory(enabled=False), - CollectionRuleFactory(enabled=True), - ) + rules = (FeedFactory(enabled=False), FeedFactory(enabled=True)) self.mocked_parse.return_value = multiple_mock diff --git a/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py index 109491b..18a6c6c 100644 --- a/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py +++ b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py @@ -6,7 +6,7 @@ from django.utils import timezone from freezegun import freeze_time from newsreader.news.collection.feed import FeedDuplicateHandler -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory from newsreader.news.core.models import Post from newsreader.news.core.tests.factories import PostFactory @@ -17,7 +17,7 @@ class FeedDuplicateHandlerTestCase(TestCase): self.maxDiff = None def test_duplicate_entries_with_remote_identifiers(self): - rule = CollectionRuleFactory() + rule = FeedFactory() existing_post = PostFactory.create( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", rule=rule @@ -52,7 +52,7 @@ class FeedDuplicateHandlerTestCase(TestCase): self.assertEquals(post.read, False) def test_duplicate_entries_with_different_remote_identifiers(self): - rule = CollectionRuleFactory() + rule = FeedFactory() existing_post = PostFactory( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", @@ -98,7 +98,7 @@ class FeedDuplicateHandlerTestCase(TestCase): self.assertEquals(post.read, False) def test_duplicate_entries_in_recent_database(self): - rule = CollectionRuleFactory() + rule = FeedFactory() existing_post = PostFactory( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", @@ -145,7 +145,7 @@ class FeedDuplicateHandlerTestCase(TestCase): self.assertEquals(post.read, False) def test_multiple_existing_entries_with_identifier(self): - rule = CollectionRuleFactory() + rule = FeedFactory() PostFactory.create_batch( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", rule=rule, size=5 @@ -187,7 +187,7 @@ class FeedDuplicateHandlerTestCase(TestCase): self.assertEquals(post.read, False) def test_duplicate_entries_outside_time_slot(self): - rule = CollectionRuleFactory() + rule = FeedFactory() existing_post = PostFactory( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", @@ -234,7 +234,7 @@ class FeedDuplicateHandlerTestCase(TestCase): self.assertEquals(post.read, False) def test_duplicate_entries_in_collected_entries(self): - rule = CollectionRuleFactory() + rule = FeedFactory() post_1 = PostFactory.build( title="title got updated", body="body", diff --git a/src/newsreader/news/collection/tests/feed/stream/mocks.py b/src/newsreader/news/collection/tests/feed/stream/mocks.py index 7dfeba6..4218355 100644 --- a/src/newsreader/news/collection/tests/feed/stream/mocks.py +++ b/src/newsreader/news/collection/tests/feed/stream/mocks.py @@ -1,59 +1,174 @@ from time import struct_time -simple_mock = { - "bozo": 1, +simple_mock = bytes( + """ + + + + <![CDATA[BBC News - Home]]> + + https://www.bbc.co.uk/news/ + + https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif + BBC News - Home + https://www.bbc.co.uk/news/ + + RSS for Node + Sun, 12 Jul 2020 17:21:20 GMT + + + 15 + + <![CDATA[Coronavirus: I trust people's sense on face masks - Gove]]> + + https://www.bbc.co.uk/news/uk-53381000 + https://www.bbc.co.uk/news/uk-53381000 + Sun, 12 Jul 2020 16:15:03 GMT + + + <![CDATA[Farm outbreak leads 200 to self isolate ]]> + + https://www.bbc.co.uk/news/uk-england-hereford-worcester-53381802 + https://www.bbc.co.uk/news/uk-england-hereford-worcester-53381802 + Sun, 12 Jul 2020 17:19:31 GMT + + + <![CDATA[English Channel search operation after migrant crossings]]> + + https://www.bbc.co.uk/news/uk-53382563 + https://www.bbc.co.uk/news/uk-53382563 + Sun, 12 Jul 2020 15:47:17 GMT + + + """, + "utf-8", +) + + +simple_mock_parsed = { + "bozo": 0, "encoding": "utf-8", "entries": [ { "guidislink": False, - "href": "", - "id": "https://www.bbc.co.uk/news/world-us-canada-48338168", - "link": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "id": "https://www.bbc.co.uk/news/uk-53381000", + "link": "https://www.bbc.co.uk/news/uk-53381000", "links": [ { - "href": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "href": "https://www.bbc.co.uk/news/uk-53381000", "rel": "alternate", "type": "text/html", } ], - "media_thumbnail": [ - { - "height": "1152", - "url": "http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg", - "width": "2048", - } - ], - "published": "Mon, 20 May 2019 16:07:37 GMT", - "published_parsed": struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), - "summary": "Foreign Minister Mohammad Javad Zarif says the US " - "president should try showing Iranians some respect.", + "published": "Sun, 12 Jul 2020 16:15:03 GMT", + "published_parsed": struct_time((2020, 7, 12, 16, 15, 3, 6, 194, 0)), + "summary": "Minister Michael Gove says he does not think face " + "coverings should be mandatory in shops in England.", "summary_detail": { - "base": "http://feeds.bbci.co.uk/news/rss.xml", + "base": "", "language": None, "type": "text/html", - "value": "Foreign Minister Mohammad Javad " - "Zarif says the US president should " - "try showing Iranians some " - "respect.", + "value": "Minister Michael Gove says he does " + "not think face coverings should be " + "mandatory in shops in England.", }, - "title": "Trump's 'genocidal taunts' will not end Iran - Zarif", + "title": "Coronavirus: I trust people's sense on face masks - " "Gove", "title_detail": { - "base": "http://feeds.bbci.co.uk/news/rss.xml", + "base": "", "language": None, "type": "text/plain", - "value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif", + "value": "Coronavirus: I trust people's sense " "on face masks - Gove", }, - } + }, + { + "guidislink": False, + "id": "https://www.bbc.co.uk/news/uk-england-hereford-worcester-53381802", + "link": "https://www.bbc.co.uk/news/uk-england-hereford-worcester-53381802", + "links": [ + { + "href": "https://www.bbc.co.uk/news/uk-england-hereford-worcester-53381802", + "rel": "alternate", + "type": "text/html", + } + ], + "published": "Sun, 12 Jul 2020 17:19:31 GMT", + "published_parsed": struct_time((2020, 7, 12, 17, 19, 31, 6, 194, 0)), + "summary": "Up to 200 vegetable pickers and packers will remain " + "on the farm in Herefordshire while isolating.", + "summary_detail": { + "base": "", + "language": None, + "type": "text/html", + "value": "Up to 200 vegetable pickers and " + "packers will remain on the farm in " + "Herefordshire while isolating.", + }, + "title": "Farm outbreak leads 200 to self isolate", + "title_detail": { + "base": "", + "language": None, + "type": "text/plain", + "value": "Farm outbreak leads 200 to self " "isolate", + }, + }, + { + "guidislink": False, + "id": "https://www.bbc.co.uk/news/uk-53382563", + "link": "https://www.bbc.co.uk/news/uk-53382563", + "links": [ + { + "href": "https://www.bbc.co.uk/news/uk-53382563", + "rel": "alternate", + "type": "text/html", + } + ], + "published": "Sun, 12 Jul 2020 15:47:17 GMT", + "published_parsed": struct_time((2020, 7, 12, 15, 47, 17, 6, 194, 0)), + "summary": "Several boats are spotted as the home secretary " + "visits France for talks on tackling people " + "smuggling.", + "summary_detail": { + "base": "", + "language": None, + "type": "text/html", + "value": "Several boats are spotted as the " + "home secretary visits France for " + "talks on tackling people " + "smuggling.", + }, + "title": "English Channel search operation after migrant " "crossings", + "title_detail": { + "base": "", + "language": None, + "type": "text/plain", + "value": "English Channel search operation " "after migrant crossings", + }, + }, ], "feed": { + "generator": "RSS for Node", + "generator_detail": {"name": "RSS for Node"}, "image": { "href": "https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif", "link": "https://www.bbc.co.uk/news/", + "links": [ + { + "href": "https://www.bbc.co.uk/news/", + "rel": "alternate", + "type": "text/html", + } + ], "title": "BBC News - Home", - "language": "en-gb", - "link": "https://www.bbc.co.uk/news/", + "title_detail": { + "base": "", + "language": None, + "type": "text/plain", + "value": "BBC News - Home", + }, }, + "language": "en-gb", + "link": "https://www.bbc.co.uk/news/", "links": [ { "href": "https://www.bbc.co.uk/news/", @@ -61,9 +176,41 @@ simple_mock = { "type": "text/html", } ], + "rights": "Copyright: (C) British Broadcasting Corporation, see " + "http://news.bbc.co.uk/2/hi/help/rss/4498287.stm for terms " + "and conditions of reuse.", + "rights_detail": { + "base": "", + "language": None, + "type": "text/plain", + "value": "Copyright: (C) British Broadcasting " + "Corporation, see " + "http://news.bbc.co.uk/2/hi/help/rss/4498287.stm " + "for terms and conditions of reuse.", + }, + "subtitle": "BBC News - Home", + "subtitle_detail": { + "base": "", + "language": None, + "type": "text/html", + "value": "BBC News - Home", + }, "title": "BBC News - Home", + "title_detail": { + "base": "", + "language": None, + "type": "text/plain", + "value": "BBC News - Home", + }, + "ttl": "15", + "updated": "Sun, 12 Jul 2020 17:21:20 GMT", + "updated_parsed": struct_time((2020, 7, 12, 17, 21, 20, 6, 194, 0)), + }, + "namespaces": { + "": "http://www.w3.org/2005/Atom", + "content": "http://purl.org/rss/1.0/modules/content/", + "dc": "http://purl.org/dc/elements/1.1/", + "media": "http://search.yahoo.com/mrss/", }, - "href": "http://feeds.bbci.co.uk/news/rss.xml", - "status": 200, "version": "rss20", } diff --git a/src/newsreader/news/collection/tests/feed/stream/tests.py b/src/newsreader/news/collection/tests/feed/stream/tests.py index 7c0f203..82a09a3 100644 --- a/src/newsreader/news/collection/tests/feed/stream/tests.py +++ b/src/newsreader/news/collection/tests/feed/stream/tests.py @@ -11,9 +11,9 @@ from newsreader.news.collection.exceptions import ( StreamTimeOutException, ) from newsreader.news.collection.feed import FeedStream -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory -from .mocks import simple_mock +from .mocks import simple_mock, simple_mock_parsed class FeedStreamTestCase(TestCase): @@ -29,19 +29,19 @@ class FeedStreamTestCase(TestCase): def test_simple_stream(self): self.mocked_fetch.return_value = MagicMock(content=simple_mock) - rule = CollectionRuleFactory() + rule = FeedFactory() stream = FeedStream(rule) data, stream = stream.read() self.mocked_fetch.assert_called_once_with(rule.url) - self.assertEquals(data["entries"], data["entries"]) - self.assertEquals(stream, stream) + self.assertEquals(data, simple_mock_parsed) + self.assertEquals(stream.rule, rule) def test_stream_raises_exception(self): self.mocked_fetch.side_effect = StreamException - rule = CollectionRuleFactory() + rule = FeedFactory() stream = FeedStream(rule) with self.assertRaises(StreamException): @@ -52,7 +52,7 @@ class FeedStreamTestCase(TestCase): def test_stream_raises_denied_exception(self): self.mocked_fetch.side_effect = StreamDeniedException - rule = CollectionRuleFactory() + rule = FeedFactory() stream = FeedStream(rule) with self.assertRaises(StreamDeniedException): @@ -63,7 +63,7 @@ class FeedStreamTestCase(TestCase): def test_stream_raises_not_found_exception(self): self.mocked_fetch.side_effect = StreamNotFoundException - rule = CollectionRuleFactory() + rule = FeedFactory() stream = FeedStream(rule) with self.assertRaises(StreamNotFoundException): @@ -74,7 +74,7 @@ class FeedStreamTestCase(TestCase): def test_stream_raises_time_out_exception(self): self.mocked_fetch.side_effect = StreamTimeOutException - rule = CollectionRuleFactory() + rule = FeedFactory() stream = FeedStream(rule) with self.assertRaises(StreamTimeOutException): @@ -85,7 +85,7 @@ class FeedStreamTestCase(TestCase): def test_stream_raises_forbidden_exception(self): self.mocked_fetch.side_effect = StreamForbiddenException - rule = CollectionRuleFactory() + rule = FeedFactory() stream = FeedStream(rule) with self.assertRaises(StreamForbiddenException): @@ -98,7 +98,7 @@ class FeedStreamTestCase(TestCase): self.mocked_fetch.return_value = MagicMock() mocked_parse.side_effect = TypeError - rule = CollectionRuleFactory() + rule = FeedFactory() stream = FeedStream(rule) with self.assertRaises(StreamParseException): diff --git a/src/newsreader/news/collection/tests/reddit/__init__.py b/src/newsreader/news/collection/tests/reddit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/reddit/builder/__init__.py b/src/newsreader/news/collection/tests/reddit/builder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/reddit/builder/mocks.py b/src/newsreader/news/collection/tests/reddit/builder/mocks.py new file mode 100644 index 0000000..53ce372 --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/builder/mocks.py @@ -0,0 +1,1378 @@ +simple_mock = { + "kind": "Listing", + "data": { + "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", + "dist": 27, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "Welcome to r/linux rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.\n\nLet us know what's annoying you, whats making you happy, or something that you want to get out to r/linux but didn't make the cut into a full post of it's own.\n\nFor those looking for certifications please use this megathread to ask about how to get certified whether it's for the business world or for your own satisfaction. Be sure to check out r/linuxadmin for more discussion in the SysAdmin world!\n\n_Please keep questions in r/linuxquestions, r/linux4noobs, or the Wednesday automod thread._", + "author_fullname": "t2_6l4z3", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hm0qct", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.7, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 8, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 8, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594037482.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": "<!-- SC_OFF --><div class='md'><p>Welcome to <a href='/r/linux'>r/linux</a> rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.</p>\n\n<p>Let us know what&#39;s annoying you, whats making you happy, or something that you want to get out to <a href='/r/linux'>r/linux</a> but didn&#39;t make the cut into a full post of it&#39;s own.</p>\n\n<p>For those looking for certifications please use this megathread to ask about how to get certified whether it&#39;s for the business world or for your own satisfaction. Be sure to check out <a href='/r/linuxadmin'>r/linuxadmin</a> for more discussion in the SysAdmin world!</p>\n\n<p><em>Please keep questions in <a href='/r/linuxquestions'>r/linuxquestions</a>, <a href='/r/linux4noobs'>r/linux4noobs</a>, or the Wednesday automod thread.</em></p>\n</div><!-- SC_ON -->", + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": "moderator", + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hm0qct", + "is_robot_indexable": True, + "report_reasons": None, + "author": "AutoModerator", + "discussion_type": None, + "num_comments": 9, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", + "subreddit_subscribers": 544037, + "created_utc": 1594008682.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "Welcome to r/linux! If you're new to Linux or trying to get started this thread is for you. Get help here or as always, check out r/linuxquestions or r/linux4noobs\n\nThis megathread is for all your question needs. As we don't allow questions on r/linux outside of this megathread, please consider using r/linuxquestions or r/linux4noobs for the best solution to your problem.\n\nAsk your hardware requests here too or try r/linuxhardware!", + "author_fullname": "t2_6l4z3", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Weekly Questions and Hardware Thread - July 08, 2020", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hna75r", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.6, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 2, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 2, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594210138.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>Welcome to <a href="/r/linux">r/linux</a>! If you&#39;re new to Linux or trying to get started this thread is for you. Get help here or as always, check out <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a></p>\n\n<p>This megathread is for all your question needs. As we don&#39;t allow questions on <a href="/r/linux">r/linux</a> outside of this megathread, please consider using <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a> for the best solution to your problem.</p>\n\n<p>Ask your hardware requests here too or try <a href="/r/linuxhardware">r/linuxhardware</a>!</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": "new", + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": "moderator", + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hna75r", + "is_robot_indexable": True, + "report_reasons": None, + "author": "AutoModerator", + "discussion_type": None, + "num_comments": 2, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", + "subreddit_subscribers": 544037, + "created_utc": 1594181338.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_gr7k5", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Here's a feature Linux could borrow from BSD: in-kernel debugger with built-in hangman game", + "link_flair_richtext": [{"e": "text", "t": "Fluff"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hngs71", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.9, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 158, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Fluff", + "can_mod_post": False, + "score": 158, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594242629.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/wmc8tp2ium951.jpg", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "af8918be-6777-11e7-8273-0e925d908786", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#9a2bff", + "id": "hngs71", + "is_robot_indexable": True, + "report_reasons": None, + "author": "the_humeister", + "discussion_type": None, + "num_comments": 21, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hngs71/heres_a_feature_linux_could_borrow_from_bsd/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/wmc8tp2ium951.jpg", + "subreddit_subscribers": 544037, + "created_utc": 1594213829.0, + "num_crossposts": 1, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_k9f35", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "KeePassXC 2.6.0 released", + "link_flair_richtext": [{"e": "text", "t": "Software Release"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hngsj8", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.97, + "author_flair_background_color": "transparent", + "subreddit_type": "public", + "ups": 151, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": "fa602e36-cdf6-11e8-93c9-0e41ac35f4cc", + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Software Release", + "can_mod_post": False, + "score": 151, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [ + { + "a": ":ubuntu:", + "e": "emoji", + "u": "https://emoji.redditmedia.com/uwmddx7qqpr11_t5_2qh1a/ubuntu", + } + ], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594242666.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "keepassxc.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://keepassxc.org/blog/2020-07-07-2.6.0-released/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "904ea3e4-6748-11e7-b925-0ef3dfbb807a", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": ":ubuntu:", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#349e48", + "id": "hngsj8", + "is_robot_indexable": True, + "report_reasons": None, + "author": "nixcraft", + "discussion_type": None, + "num_comments": 46, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/linux/comments/hngsj8/keepassxc_260_released/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://keepassxc.org/blog/2020-07-07-2.6.0-released/", + "subreddit_subscribers": 544037, + "created_utc": 1594213866.0, + "num_crossposts": 1, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [{"e": "text", "t": "Popular Application"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd7cy", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.95, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 226, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Popular Application", + "can_mod_post": False, + "score": 226, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [ + { + "approved_at_utc": None, + "subreddit": "libreoffice", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/libreoffice", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd6yo", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.96, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 29, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "News", + "can_mod_post": False, + "score": 29, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594224961.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "dc82ac98-bafb-11e4-9f88-22000b310327", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2s4nt", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnd6yo", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZero", + "discussion_type": None, + "num_comments": 38, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/libreoffice/comments/hnd6yo/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 4669, + "created_utc": 1594196161.0, + "num_crossposts": 2, + "media": None, + "is_video": False, + } + ], + "created": 1594225018.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "7127ec98-5859-11e8-9488-0e8717893ec8", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#0aa18f", + "id": "hnd7cy", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZero", + "discussion_type": None, + "num_comments": 120, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_hnd6yo", + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnd7cy/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 544037, + "created_utc": 1594196218.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + ], + "after": "t3_hmytic", + "before": None, + }, +} + +empty_mock = { + "kind": "Listing", + "data": { + "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", + "dist": 27, + "children": [], + "after": "t3_hmytic", + "before": None, + }, +} + +unknown_mock = { + "kind": "Comment", + "data": { + "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", + "dist": 27, + "after": "t3_hmytic", + "before": None, + }, +} + +unsanitized_mock = { + "kind": "Listing", + "data": { + "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", + "dist": 27, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [{"e": "text", "t": "Popular Application"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd7cy", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.95, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 226, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Popular Application", + "can_mod_post": False, + "score": 226, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [ + { + "approved_at_utc": None, + "subreddit": "libreoffice", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/libreoffice", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd6yo", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.96, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 29, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "News", + "can_mod_post": False, + "score": 29, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594224961.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "dc82ac98-bafb-11e4-9f88-22000b310327", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2s4nt", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnd6yo", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZero", + "discussion_type": None, + "num_comments": 38, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/libreoffice/comments/hnd6yo/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 4669, + "created_utc": 1594196161.0, + "num_crossposts": 2, + "media": None, + "is_video": False, + } + ], + "created": 1594225018.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": "
      ", + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "7127ec98-5859-11e8-9488-0e8717893ec8", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#0aa18f", + "id": "hnd7cy", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZero", + "discussion_type": None, + "num_comments": 120, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_hnd6yo", + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnd7cy/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 544037, + "created_utc": 1594196218.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + } + ], + "after": "t3_hmytic", + "before": None, + }, +} + +author_mock = { + "kind": "Listing", + "data": { + "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", + "dist": 27, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [{"e": "text", "t": "Popular Application"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd7cy", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.95, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 226, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Popular Application", + "can_mod_post": False, + "score": 226, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [ + { + "approved_at_utc": None, + "subreddit": "libreoffice", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/libreoffice", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd6yo", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.96, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 29, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "News", + "can_mod_post": False, + "score": 29, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594224961.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "dc82ac98-bafb-11e4-9f88-22000b310327", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2s4nt", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnd6yo", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZero", + "discussion_type": None, + "num_comments": 38, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/libreoffice/comments/hnd6yo/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 4669, + "created_utc": 1594196161.0, + "num_crossposts": 2, + "media": None, + "is_video": False, + } + ], + "created": 1594225018.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": "
      ", + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "7127ec98-5859-11e8-9488-0e8717893ec8", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#0aa18f", + "id": "hnd7cy", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZeroTheQuantumZeroTheQuantumZero", + "discussion_type": None, + "num_comments": 120, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_hnd6yo", + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnd7cy/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 544037, + "created_utc": 1594196218.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + } + ], + "after": "t3_hmytic", + "before": None, + }, +} + +title_mock = { + "kind": "Listing", + "data": { + "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", + "dist": 27, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal EditionBoard statement on the LibreOffice 7.0 RC "Personal Edition" label" labelBoard statement on the LibreOffice 7.0 RC "PersBoard statement on the LibreOffice 7.0 RC "Personal Edition" labelonal Edition" label', + "link_flair_richtext": [{"e": "text", "t": "Popular Application"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd7cy", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.95, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 226, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Popular Application", + "can_mod_post": False, + "score": 226, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [ + { + "approved_at_utc": None, + "subreddit": "libreoffice", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/libreoffice", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd6yo", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.96, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 29, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "News", + "can_mod_post": False, + "score": 29, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594224961.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "dc82ac98-bafb-11e4-9f88-22000b310327", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2s4nt", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnd6yo", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZero", + "discussion_type": None, + "num_comments": 38, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/libreoffice/comments/hnd6yo/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 4669, + "created_utc": 1594196161.0, + "num_crossposts": 2, + "media": None, + "is_video": False, + } + ], + "created": 1594225018.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": "
      ", + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "7127ec98-5859-11e8-9488-0e8717893ec8", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#0aa18f", + "id": "hnd7cy", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZeroTheQuantumZeroTheQuantumZero", + "discussion_type": None, + "num_comments": 120, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_hnd6yo", + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnd7cy/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 544037, + "created_utc": 1594196218.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + } + ], + "after": "t3_hmytic", + "before": None, + }, +} diff --git a/src/newsreader/news/collection/tests/reddit/builder/tests.py b/src/newsreader/news/collection/tests/reddit/builder/tests.py new file mode 100644 index 0000000..3085199 --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/builder/tests.py @@ -0,0 +1,185 @@ +from datetime import datetime +from unittest.mock import MagicMock + +from django.test import TestCase + +import pytz + +from newsreader.news.collection.reddit import RedditBuilder +from newsreader.news.collection.tests.factories import SubredditFactory +from newsreader.news.collection.tests.reddit.builder.mocks import ( + author_mock, + empty_mock, + simple_mock, + title_mock, + unknown_mock, + unsanitized_mock, +) +from newsreader.news.core.models import Post +from newsreader.news.core.tests.factories import PostFactory + + +class RedditBuilderTestCase(TestCase): + def setUp(self): + self.maxDiff = None + + def test_simple_mock(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((simple_mock, mock_stream)) as builder: + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual( + ("hm0qct", "hna75r", "hngs71", "hngsj8", "hnd7cy"), posts.keys() + ) + + post = posts["hm0qct"] + + self.assertEquals(post.rule, subreddit) + self.assertEquals( + post.title, + "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", + ) + self.assertIn( + " This megathread is also to hear opinions from anyone just starting out" + " with Linux or those that have used Linux (GNU or otherwise) for a long", + post.body, + ) + + self.assertIn( + "

      For those looking for certifications please use this megathread to ask about how" + " to get certified whether it's for the business world or for your own satisfaction." + ' Be sure to check out r/linuxadmin for more discussion in the' + " SysAdmin world!

      ", + post.body, + ) + + self.assertEquals(post.author, "AutoModerator") + self.assertEquals( + post.url, + "https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", + ) + self.assertEquals( + post.publication_date, pytz.utc.localize(datetime(2020, 7, 6, 6, 11, 22)) + ) + + def test_empty_data(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((empty_mock, mock_stream)) as builder: + builder.save() + + self.assertEquals(Post.objects.count(), 0) + + def test_unknown_mock(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((unknown_mock, mock_stream)) as builder: + builder.save() + + self.assertEquals(Post.objects.count(), 0) + + def test_update_posts(self): + subreddit = SubredditFactory() + existing_publication_date = pytz.utc.localize(datetime(2020, 7, 8, 14, 0, 0)) + existing_post = PostFactory( + remote_identifier="hngsj8", + publication_date=existing_publication_date, + author="Old author", + title="Old title", + body="Old body", + url="https://bbc.com/", + rule=subreddit, + ) + + builder = RedditBuilder + mock_stream = MagicMock(rule=subreddit) + + with builder((simple_mock, mock_stream)) as builder: + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual( + ("hm0qct", "hna75r", "hngs71", "hngsj8", "hnd7cy"), posts.keys() + ) + + existing_post.refresh_from_db() + + self.assertEquals(existing_post.remote_identifier, "hngsj8") + self.assertEquals(existing_post.author, "nixcraft") + self.assertEquals(existing_post.title, "KeePassXC 2.6.0 released") + self.assertEquals(existing_post.body, "") + self.assertEquals( + existing_post.publication_date, + pytz.utc.localize(datetime(2020, 7, 8, 15, 11, 6)), + ) + self.assertEquals( + existing_post.url, + "https://www.reddit.com/r/linux/comments/hngsj8/" "keepassxc_260_released/", + ) + + def test_html_sanitizing(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((unsanitized_mock, mock_stream)) as builder: + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual(("hnd7cy",), posts.keys()) + + post = posts["hnd7cy"] + + self.assertEquals(post.body, "
      ") + + def test_long_author_text_is_truncated(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((author_mock, mock_stream)) as builder: + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual(("hnd7cy",), posts.keys()) + + post = posts["hnd7cy"] + + self.assertEquals(post.author, "TheQuantumZeroTheQuantumZeroTheQuantumZ…") + + def test_long_title_text_is_truncated(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((title_mock, mock_stream)) as builder: + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual(("hnd7cy",), posts.keys()) + + post = posts["hnd7cy"] + + self.assertEquals( + post.title, + 'Board statement on the LibreOffice 7.0 RC "Personal EditionBoard statement on the LibreOffice 7.0 RC "Personal Edition" label" labelBoard statement on the LibreOffice 7.0 RC "PersBoard statement on t…', + ) diff --git a/src/newsreader/news/collection/tests/reddit/client/__init__.py b/src/newsreader/news/collection/tests/reddit/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/reddit/client/mocks.py b/src/newsreader/news/collection/tests/reddit/client/mocks.py new file mode 100644 index 0000000..6a11409 --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/client/mocks.py @@ -0,0 +1,160 @@ +# Note that some response data is truncated + +simple_mock = { + "data": { + "after": "t3_hjywyf", + "before": None, + "children": [ + { + "data": { + "approved_at_utc": None, + "approved_by": None, + "archived": False, + "author": "AutoModerator", + "banned_at_utc": None, + "banned_by": None, + "category": None, + "content_categories": None, + "created": 1593605471.0, + "created_utc": 1593576671.0, + "discussion_type": None, + "distinguished": "moderator", + "domain": "self.linux", + "edited": False, + "hidden": False, + "id": "hj34ck", + "locked": False, + "name": "t3_hj34ck", + "permalink": "/r/linux/comments/hj34ck/weekly_questions_and_hardware_thread_july_01_2020/", + "pinned": False, + "selftext": "Welcome to r/linux! If you're " + "new to Linux or trying to get " + "started this thread is for you. " + "Get help here or as always, " + "check out r/linuxquestions or " + "r/linux4noobs\n" + "\n" + "This megathread is for all your " + "question needs. As we don't " + "allow questions on r/linux " + "outside of this megathread, " + "please consider using " + "r/linuxquestions or " + "r/linux4noobs for the best " + "solution to your problem.\n" + "\n" + "Ask your hardware requests here " + "too or try r/linuxhardware!", + "selftext_html": "<!-- SC_OFF " + "--><div " + 'class="md"><p>Welcome ' + "to <a " + 'href="/r/linux">r/linux</a>! ' + "If you&#39;re new to " + "Linux or trying to get " + "started this thread is for " + "you. Get help here or as " + "always, check out <a " + 'href="/r/linuxquestions">r/linuxquestions</a> ' + "or <a " + 'href="/r/linux4noobs">r/linux4noobs</a></p>\n' + "\n" + "<p>This megathread is " + "for all your question " + "needs. As we don&#39;t " + "allow questions on <a " + 'href="/r/linux">r/linux</a> ' + "outside of this megathread, " + "please consider using <a " + 'href="/r/linuxquestions">r/linuxquestions</a> ' + "or <a " + 'href="/r/linux4noobs">r/linux4noobs</a> ' + "for the best solution to " + "your problem.</p>\n" + "\n" + "<p>Ask your hardware " + "requests here too or try " + "<a " + 'href="/r/linuxhardware">r/linuxhardware</a>!</p>\n' + "</div><!-- SC_ON " + "-->", + "spoiler": False, + "stickied": True, + "subreddit": "linux", + "subreddit_id": "t5_2qh1a", + "subreddit_name_prefixed": "r/linux", + "title": "Weekly Questions and Hardware " "Thread - July 01, 2020", + "url": "https://www.reddit.com/r/linux/comments/hj34ck/weekly_questions_and_hardware_thread_july_01_2020/", + "visited": False, + }, + "kind": "t3", + }, + { + "data": { + "archived": False, + "author": "AutoModerator", + "banned_at_utc": None, + "banned_by": None, + "category": None, + "created": 1593824903.0, + "created_utc": 1593796103.0, + "discussion_type": None, + "domain": "self.linux", + "edited": False, + "hidden": False, + "id": "hkmu0t", + "name": "t3_hkmu0t", + "permalink": "/r/linux/comments/hkmu0t/weekend_fluff_linux_in_the_wild_thread_july_03/", + "pinned": False, + "saved": False, + "selftext": "Welcome to the weekend! This " + "stickied thread is for you to " + "post pictures of your ubuntu " + "2006 install disk, slackware " + "floppies, on-topic memes or " + "more.\n" + "\n" + "When it's not the weekend, be " + "sure to check out " + "r/WildLinuxAppears or " + "r/linuxmemes!", + "selftext_html": "<!-- SC_OFF " + "--><div " + 'class="md"><p>Welcome ' + "to the weekend! This " + "stickied thread is for you " + "to post pictures of your " + "ubuntu 2006 install disk, " + "slackware floppies, " + "on-topic memes or " + "more.</p>\n" + "\n" + "<p>When it&#39;s " + "not the weekend, be sure to " + "check out <a " + 'href="/r/WildLinuxAppears">r/WildLinuxAppears</a> ' + "or <a " + 'href="/r/linuxmemes">r/linuxmemes</a>!</p>\n' + "</div><!-- SC_ON " + "-->", + "spoiler": False, + "stickied": True, + "subreddit": "linux", + "subreddit_id": "t5_2qh1a", + "subreddit_name_prefixed": "r/linux", + "subreddit_subscribers": 542073, + "subreddit_type": "public", + "thumbnail": "", + "title": "Weekend Fluff / Linux in the Wild " + "Thread - July 03, 2020", + "url": "https://www.reddit.com/r/linux/comments/hkmu0t/weekend_fluff_linux_in_the_wild_thread_july_03/", + "visited": False, + }, + "kind": "t3", + }, + ], + "dist": 27, + "modhash": None, + }, + "kind": "Listing", +} diff --git a/src/newsreader/news/collection/tests/reddit/client/tests.py b/src/newsreader/news/collection/tests/reddit/client/tests.py new file mode 100644 index 0000000..f2ee84d --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/client/tests.py @@ -0,0 +1,164 @@ +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +from django.test import TestCase +from django.utils.lorem_ipsum import words + +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamException, + StreamNotFoundException, + StreamParseException, + StreamTimeOutException, + StreamTooManyException, +) +from newsreader.news.collection.reddit import RedditClient +from newsreader.news.collection.tests.factories import SubredditFactory + +from .mocks import simple_mock + + +class RedditClientTestCase(TestCase): + def setUp(self): + self.maxDiff = None + + self.patched_read = patch("newsreader.news.collection.reddit.RedditStream.read") + self.mocked_read = self.patched_read.start() + + def tearDown(self): + patch.stopall() + + def test_client_retrieves_single_rules(self): + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + self.mocked_read.return_value = (simple_mock, mock_stream) + + with RedditClient([[subreddit]]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertEquals(data, simple_mock) + self.assertEquals(stream, mock_stream) + + self.mocked_read.assert_called_once_with() + + def test_client_catches_stream_exception(self): + subreddit = SubredditFactory() + + self.mocked_read.side_effect = StreamException(message="Stream exception") + + with RedditClient([[subreddit]]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertEquals(data, None) + self.assertEquals(stream, None) + self.assertEquals(stream.rule.error, "Stream exception") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called_once_with() + + def test_client_catches_stream_not_found_exception(self): + subreddit = SubredditFactory.create() + + self.mocked_read.side_effect = StreamNotFoundException( + message="Stream not found" + ) + + with RedditClient([[subreddit]]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertEquals(data, None) + self.assertEquals(stream, None) + self.assertEquals(stream.rule.error, "Stream not found") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called_once_with() + + @patch("newsreader.news.collection.reddit.RedditTokenTask") + def test_client_catches_stream_denied_exception(self, mocked_task): + user = UserFactory( + reddit_access_token=str(uuid4()), reddit_refresh_token=str(uuid4()) + ) + subreddit = SubredditFactory(user=user) + + self.mocked_read.side_effect = StreamDeniedException(message="Token expired") + + with RedditClient([(subreddit,)]) as client: + results = [(data, stream) for data, stream in client] + + self.mocked_read.assert_called_once_with() + mocked_task.delay.assert_called_once_with(user.pk) + + self.assertEquals(len(results), 0) + + user.refresh_from_db() + subreddit.refresh_from_db() + + self.assertEquals(user.reddit_access_token, None) + self.assertEquals(subreddit.succeeded, False) + self.assertEquals(subreddit.error, "Token expired") + + def test_client_catches_stream_timed_out_exception(self): + subreddit = SubredditFactory() + + self.mocked_read.side_effect = StreamTimeOutException( + message="Stream timed out" + ) + + with RedditClient([[subreddit]]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertEquals(data, None) + self.assertEquals(stream, None) + self.assertEquals(stream.rule.error, "Stream timed out") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called_once_with() + + def test_client_catches_stream_too_many_exception(self): + subreddit = SubredditFactory() + + self.mocked_read.side_effect = StreamTooManyException + + with RedditClient([[subreddit]]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertEquals(data, None) + self.assertEquals(stream, None) + self.assertEquals(stream.rule.error, "Too many requests") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called_once_with() + + def test_client_catches_stream_parse_exception(self): + subreddit = SubredditFactory() + + self.mocked_read.side_effect = StreamParseException( + message="Stream could not be parsed" + ) + + with RedditClient([[subreddit]]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertEquals(data, None) + self.assertEquals(stream, None) + self.assertEquals(stream.rule.error, "Stream could not be parsed") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called_once_with() + + def test_client_catches_long_exception_text(self): + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + self.mocked_read.side_effect = StreamParseException(message=words(1000)) + + with RedditClient([[subreddit]]) as client: + for data, stream in client: + self.assertEquals(data, None) + self.assertEquals(stream, None) + self.assertEquals(len(stream.rule.error), 1024) + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called_once_with() diff --git a/src/newsreader/news/collection/tests/reddit/collector/__init__.py b/src/newsreader/news/collection/tests/reddit/collector/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/reddit/collector/mocks.py b/src/newsreader/news/collection/tests/reddit/collector/mocks.py new file mode 100644 index 0000000..37d40d8 --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/collector/mocks.py @@ -0,0 +1,1662 @@ +simple_mock_1 = { + "kind": "Listing", + "data": { + "modhash": "khwcr8tmp613f1b92d55150adb744983e7f6c37e87e30f6432", + "dist": 26, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "starcitizen", + "selftext": "Welcome to the Star Citizen question and answer thread. Feel free to ask any questions you have related to SC here!\r\n\r\n---\r\n\r\nUseful Links and Resources:\r\n\r\n[Star Citizen Wiki](https://starcitizen.tools) - *The biggest and best wiki resource dedicated to Star Citizen*\r\n\r\n[Star Citizen FAQ](https://starcitizen.tools/Frequently_Asked_Questions) - *Chances the answer you need is here.* \r\n\r\n[Discord Help Channel](https://discord.gg/0STCP5tSe7x9NBSq) - *Often times community members will be here to help you with issues.*\r\n\r\n[Referral Code Randomizer](http://gorefer.me/starcitizen) - *Use this when creating a new account to get 5000 extra UEC.*\r\n\r\n[Download Star Citizen](https://robertsspaceindustries.com/download) - *Get the latest version of Star Citizen here*\r\n\r\n[Current Game Features](https://robertsspaceindustries.com/feature-list) - *Click here to see what you can currently do in Star Citizen.*\r\n\r\n[Development Roadmap](https://robertsspaceindustries.com/roadmap/board/1-Star-Citizen) - *The current development status of up and coming Star Citizen features.*\r\n\r\n[Pledge FAQ](https://support.robertsspaceindustries.com/hc/en-us/articles/115013194987-Pledges-FAQs) - *Official FAQ regarding spending money on the game.*", + "author_fullname": "t2_otk50", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Star Citizen: Question and Answer Thread", + "link_flair_richtext": [{"e": "text", "t": "QUESTION"}], + "subreddit_name_prefixed": "r/starcitizen", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "QUESTION", + "downs": 0, + "thumbnail_height": None, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hm6byg", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.9, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 21, + "total_awards_received": 0, + "media_embed": {}, + "thumbnail_width": None, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "QUESTION", + "can_mod_post": False, + "score": 21, + "approved_by": None, + "author_premium": False, + "thumbnail": "self", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "post_hint": "self", + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594065605, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.starcitizen", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>Welcome to the Star Citizen question and answer thread. Feel free to ask any questions you have related to SC here!</p>\n\n<hr/>\n\n<p>Useful Links and Resources:</p>\n\n<p><a href="https://starcitizen.tools">Star Citizen Wiki</a> - <em>The biggest and best wiki resource dedicated to Star Citizen</em></p>\n\n<p><a href="https://starcitizen.tools/Frequently_Asked_Questions">Star Citizen FAQ</a> - <em>Chances the answer you need is here.</em> </p>\n\n<p><a href="https://discord.gg/0STCP5tSe7x9NBSq">Discord Help Channel</a> - <em>Often times community members will be here to help you with issues.</em></p>\n\n<p><a href="http://gorefer.me/starcitizen">Referral Code Randomizer</a> - <em>Use this when creating a new account to get 5000 extra UEC.</em></p>\n\n<p><a href="https://robertsspaceindustries.com/download">Download Star Citizen</a> - <em>Get the latest version of Star Citizen here</em></p>\n\n<p><a href="https://robertsspaceindustries.com/feature-list">Current Game Features</a> - <em>Click here to see what you can currently do in Star Citizen.</em></p>\n\n<p><a href="https://robertsspaceindustries.com/roadmap/board/1-Star-Citizen">Development Roadmap</a> - <em>The current development status of up and coming Star Citizen features.</em></p>\n\n<p><a href="https://support.robertsspaceindustries.com/hc/en-us/articles/115013194987-Pledges-FAQs">Pledge FAQ</a> - <em>Official FAQ regarding spending money on the game.</em></p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": "new", + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "preview": { + "images": [ + { + "source": { + "url": "https://external-preview.redd.it/5qLIX6ObbErxkDiTz24uP6TbZGNVNyy2SeXusPhzaKA.jpg?auto=webp&s=738b5270a81373916191470a1da34cdcc54d8511", + "width": 332, + "height": 360, + }, + "resolutions": [ + { + "url": "https://external-preview.redd.it/5qLIX6ObbErxkDiTz24uP6TbZGNVNyy2SeXusPhzaKA.jpg?width=108&crop=smart&auto=webp&s=e2ee2a9dae15472663b52c8cb4e002fdbbb6378c", + "width": 108, + "height": 117, + }, + { + "url": "https://external-preview.redd.it/5qLIX6ObbErxkDiTz24uP6TbZGNVNyy2SeXusPhzaKA.jpg?width=216&crop=smart&auto=webp&s=3690c60a9b533d376f159f306c6667b47ff42102", + "width": 216, + "height": 234, + }, + { + "url": "https://external-preview.redd.it/5qLIX6ObbErxkDiTz24uP6TbZGNVNyy2SeXusPhzaKA.jpg?width=320&crop=smart&auto=webp&s=4dcb434a5071329ecbb9f3543e4d06442ab141df", + "width": 320, + "height": 346, + }, + ], + "variants": {}, + "id": "KTE3H6RnWCasOJCFtdmgmw51FMzxSqXz_SRD6W5Rdsc", + } + ], + "enabled": False, + }, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2v94d", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hm6byg", + "is_robot_indexable": True, + "report_reasons": None, + "author": "UEE_Central_Computer", + "discussion_type": None, + "num_comments": 380, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/starcitizen/comments/hm6byg/star_citizen_question_and_answer_thread/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/starcitizen/comments/hm6byg/star_citizen_question_and_answer_thread/", + "subreddit_subscribers": 213071, + "created_utc": 1594036805, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "starcitizen", + "selftext": "", + "author_fullname": "t2_6wgp9w28", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "5 random people in a train felt like such a rare and special thing 😁", + "link_flair_richtext": [{"e": "text", "t": "FLUFF"}], + "subreddit_name_prefixed": "r/starcitizen", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "fluff", + "downs": 0, + "thumbnail_height": 78, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hpkhgj", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.98, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 892, + "total_awards_received": 0, + "media_embed": {}, + "thumbnail_width": 140, + "author_flair_template_id": "a87724f8-c2b5-11e4-b7e0-22000b2103f6", + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "FLUFF", + "can_mod_post": False, + "score": 892, + "approved_by": None, + "author_premium": False, + "thumbnail": "https://b.thumbs.redditmedia.com/YlF6BTm-DfnrZBeukYiOyrP-Fkj2xUQtk_V8ZeUD93w.jpg", + "edited": False, + "author_flair_css_class": "aurora", + "author_flair_richtext": [ + {"e": "text", "t": "🌌2013Backer🎮vGameDev🌌"} + ], + "gildings": {}, + "post_hint": "image", + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594540209, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/0jkge020fba51.png", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "preview": { + "images": [ + { + "source": { + "url": "https://preview.redd.it/0jkge020fba51.png?auto=webp&s=c3a2b8cb860f839638a364d49abca04fd4f42094", + "width": 2560, + "height": 1440, + }, + "resolutions": [ + { + "url": "https://preview.redd.it/0jkge020fba51.png?width=108&crop=smart&auto=webp&s=778a7f7d9b2e0d713161e84b32c467ebde6cbc17", + "width": 108, + "height": 60, + }, + { + "url": "https://preview.redd.it/0jkge020fba51.png?width=216&crop=smart&auto=webp&s=53afc50cc2dd6c72470e76a4c3ff8ef597f66e0d", + "width": 216, + "height": 121, + }, + { + "url": "https://preview.redd.it/0jkge020fba51.png?width=320&crop=smart&auto=webp&s=089f9ff42e429b5062c143695e695cbb4ea5b679", + "width": 320, + "height": 180, + }, + { + "url": "https://preview.redd.it/0jkge020fba51.png?width=640&crop=smart&auto=webp&s=045327ac6fd113630c0faef426d86efaf04f55e2", + "width": 640, + "height": 360, + }, + { + "url": "https://preview.redd.it/0jkge020fba51.png?width=960&crop=smart&auto=webp&s=efbdc9ddcda1207fafa20bb45e82fbe24ed37df8", + "width": 960, + "height": 540, + }, + { + "url": "https://preview.redd.it/0jkge020fba51.png?width=1080&crop=smart&auto=webp&s=1b94c9951c60a788357dfa0fe21dd983efdcf1e7", + "width": 1080, + "height": 607, + }, + ], + "variants": {}, + "id": "r-JjrJn0RtZLaxMk_d-TCfW80pWgJ-5kjMaje54J5_I", + } + ], + "enabled": True, + }, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "db099dc4-3538-11e5-97ec-0e7f0fa558f9", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": "🌌2013Backer🎮vGameDev🌌", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2v94d", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#007373", + "id": "hpkhgj", + "is_robot_indexable": True, + "report_reasons": None, + "author": "Y_DK_Y", + "discussion_type": None, + "num_comments": 39, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/starcitizen/comments/hpkhgj/5_random_people_in_a_train_felt_like_such_a_rare/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/0jkge020fba51.png", + "subreddit_subscribers": 213071, + "created_utc": 1594511409, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "starcitizen", + "selftext": "", + "author_fullname": "t2_4brylpu5", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Drake Interplanetary Smartkey thing that I made!", + "link_flair_richtext": [{"e": "text", "t": "ARTWORK"}], + "subreddit_name_prefixed": "r/starcitizen", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "artwork", + "downs": 0, + "thumbnail_height": 78, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hph00n", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.97, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 547, + "total_awards_received": 1, + "media_embed": {}, + "thumbnail_width": 140, + "author_flair_template_id": None, + "is_original_content": True, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "ARTWORK", + "can_mod_post": False, + "score": 547, + "approved_by": None, + "author_premium": False, + "thumbnail": "https://b.thumbs.redditmedia.com/gr7RYEjNN5FNc42LxuizFW_ZxWtS3xbZj1QfhIa-2Hw.jpg", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "post_hint": "image", + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594527804, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/b6h74eljeaa51.png", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "preview": { + "images": [ + { + "source": { + "url": "https://preview.redd.it/b6h74eljeaa51.png?auto=webp&s=fd286c2dcd98378c34fde6e245cf13c357716dca", + "width": 1920, + "height": 1080, + }, + "resolutions": [ + { + "url": "https://preview.redd.it/b6h74eljeaa51.png?width=108&crop=smart&auto=webp&s=3150c2a2643d178eba735cb0bc222b8b29f46c8c", + "width": 108, + "height": 60, + }, + { + "url": "https://preview.redd.it/b6h74eljeaa51.png?width=216&crop=smart&auto=webp&s=9120ce40ce7439ca4d3431da7782a8c6acd2eebf", + "width": 216, + "height": 121, + }, + { + "url": "https://preview.redd.it/b6h74eljeaa51.png?width=320&crop=smart&auto=webp&s=83cd5c93fe7a19e5643df38eec3aefee54912faf", + "width": 320, + "height": 180, + }, + { + "url": "https://preview.redd.it/b6h74eljeaa51.png?width=640&crop=smart&auto=webp&s=b3e280a4a7fbaf794692c01f4ff63af0b8559700", + "width": 640, + "height": 360, + }, + { + "url": "https://preview.redd.it/b6h74eljeaa51.png?width=960&crop=smart&auto=webp&s=8ebac203688ba0e42c7975f3d7688dab25fc065b", + "width": 960, + "height": 540, + }, + { + "url": "https://preview.redd.it/b6h74eljeaa51.png?width=1080&crop=smart&auto=webp&s=8350e0b4e004820ef9f30501397d49a2121186ec", + "width": 1080, + "height": 607, + }, + ], + "variants": {}, + "id": "B2HxXfFibxKUtHO9eBwT-Bt_VrE870XhC0R5OFA95rI", + } + ], + "enabled": True, + }, + "all_awardings": [ + { + "giver_coin_reward": 0, + "subreddit_id": None, + "is_new": False, + "days_of_drip_extension": 0, + "coin_price": 50, + "id": "award_02d9ab2c-162e-4c01-8438-317a016ed3d9", + "penny_donate": 0, + "award_sub_type": "GLOBAL", + "coin_reward": 0, + "icon_url": "https://i.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png", + "days_of_premium": 0, + "resized_icons": [ + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=16&height=16&auto=webp&s=92e96be1dbd278dc987fbd9acc1bd5078566f254", + "width": 16, + "height": 16, + }, + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=32&height=32&auto=webp&s=83e14655f2b162b295f7d2c7058b9ad94cf8b73c", + "width": 32, + "height": 32, + }, + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=48&height=48&auto=webp&s=83038a4d6181d3c8f5107dbca4ddb735ca6c2231", + "width": 48, + "height": 48, + }, + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=64&height=64&auto=webp&s=3c4e39a7664d799ff50f32e9a3f96c3109d2e266", + "width": 64, + "height": 64, + }, + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=128&height=128&auto=webp&s=390bf9706b8e1a6215716ebcf6363373f125c339", + "width": 128, + "height": 128, + }, + ], + "icon_width": 2048, + "static_icon_width": 2048, + "start_date": None, + "is_enabled": True, + "description": "I'm in this with you.", + "end_date": None, + "subreddit_coin_reward": 0, + "count": 1, + "static_icon_height": 2048, + "name": "Take My Energy", + "resized_static_icons": [ + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=16&height=16&auto=webp&s=92e96be1dbd278dc987fbd9acc1bd5078566f254", + "width": 16, + "height": 16, + }, + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=32&height=32&auto=webp&s=83e14655f2b162b295f7d2c7058b9ad94cf8b73c", + "width": 32, + "height": 32, + }, + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=48&height=48&auto=webp&s=83038a4d6181d3c8f5107dbca4ddb735ca6c2231", + "width": 48, + "height": 48, + }, + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=64&height=64&auto=webp&s=3c4e39a7664d799ff50f32e9a3f96c3109d2e266", + "width": 64, + "height": 64, + }, + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=128&height=128&auto=webp&s=390bf9706b8e1a6215716ebcf6363373f125c339", + "width": 128, + "height": 128, + }, + ], + "icon_format": "PNG", + "icon_height": 2048, + "penny_price": 0, + "award_type": "global", + "static_icon_url": "https://i.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png", + } + ], + "awarders": [], + "media_only": False, + "link_flair_template_id": "e3bb68b2-3538-11e5-bf5a-0e09b4299f63", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2v94d", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#ff66ac", + "id": "hph00n", + "is_robot_indexable": True, + "report_reasons": None, + "author": "HannahB888", + "discussion_type": None, + "num_comments": 38, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/starcitizen/comments/hph00n/drake_interplanetary_smartkey_thing_that_i_made/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/b6h74eljeaa51.png", + "subreddit_subscribers": 213071, + "created_utc": 1594499004, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "starcitizen", + "selftext": "", + "author_fullname": "t2_exlc6", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "A Historical Moment for CIG", + "link_flair_richtext": [{"e": "text", "t": "FLUFF"}], + "subreddit_name_prefixed": "r/starcitizen", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "fluff", + "downs": 0, + "thumbnail_height": 37, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hp9mlw", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.98, + "author_flair_background_color": "", + "subreddit_type": "public", + "ups": 1444, + "total_awards_received": 0, + "media_embed": {}, + "thumbnail_width": 140, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "FLUFF", + "can_mod_post": False, + "score": 1444, + "approved_by": None, + "author_premium": False, + "thumbnail": "https://b.thumbs.redditmedia.com/YYdiE2x8fsn0ckVJiGCnBzUIOa1DA03ALh3TJuVlZks.jpg", + "edited": False, + "author_flair_css_class": "carrack", + "author_flair_richtext": [{"e": "text", "t": "AHV Artemis"}], + "gildings": {}, + "post_hint": "image", + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594501406, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/fdh2ujp388a51.png", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "preview": { + "images": [ + { + "source": { + "url": "https://preview.redd.it/fdh2ujp388a51.png?auto=webp&s=605044c2757c1b5ca9060d3ec448090396a2f0dd", + "width": 424, + "height": 114, + }, + "resolutions": [ + { + "url": "https://preview.redd.it/fdh2ujp388a51.png?width=108&crop=smart&auto=webp&s=9789c6b76d45e46645fe2454555bfbd042a39815", + "width": 108, + "height": 29, + }, + { + "url": "https://preview.redd.it/fdh2ujp388a51.png?width=216&crop=smart&auto=webp&s=3f419183835c883f10b1caab3a7ecbec4ebbf3ec", + "width": 216, + "height": 58, + }, + { + "url": "https://preview.redd.it/fdh2ujp388a51.png?width=320&crop=smart&auto=webp&s=695ff914462b5b9bc253ce26f4a51f5f22641148", + "width": 320, + "height": 86, + }, + ], + "variants": {}, + "id": "XWdU5CBWG0-5mOzBRF65OnvZzQm2Btd2ldGMeJ8u_gI", + } + ], + "enabled": True, + }, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "db099dc4-3538-11e5-97ec-0e7f0fa558f9", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": "AHV Artemis", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2v94d", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#007373", + "id": "hp9mlw", + "is_robot_indexable": True, + "report_reasons": None, + "author": "sam00197", + "discussion_type": None, + "num_comments": 194, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/starcitizen/comments/hp9mlw/a_historical_moment_for_cig/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/fdh2ujp388a51.png", + "subreddit_subscribers": 213071, + "created_utc": 1594472606, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "starcitizen", + "selftext": "", + "author_fullname": "t2_4dgjlpn7", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "This view. What's your favorite moon?", + "link_flair_richtext": [{"e": "text", "t": "DISCUSSION"}], + "subreddit_name_prefixed": "r/starcitizen", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "discussion", + "downs": 0, + "thumbnail_height": 78, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hpjn8x", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.96, + "author_flair_background_color": "", + "subreddit_type": "public", + "ups": 182, + "total_awards_received": 0, + "media_embed": {}, + "thumbnail_width": 140, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "DISCUSSION", + "can_mod_post": False, + "score": 182, + "approved_by": None, + "author_premium": False, + "thumbnail": "https://a.thumbs.redditmedia.com/tKHL_2fn4Zo9FhrtP3UiJlQA7xkMU7-iN0ntJbhfa80.jpg", + "edited": False, + "author_flair_css_class": "", + "author_flair_richtext": [{"e": "text", "t": "new user/low karma"}], + "gildings": {}, + "post_hint": "image", + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594537150, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/ovly7f9g6ba51.jpg", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "preview": { + "images": [ + { + "source": { + "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?auto=webp&s=d7051e4c713e39c642c583e5e8ada57c9660fa26", + "width": 2560, + "height": 1440, + }, + "resolutions": [ + { + "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?width=108&crop=smart&auto=webp&s=35f6ebe4531c12bc24532f01741bcf8100d954b2", + "width": 108, + "height": 60, + }, + { + "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?width=216&crop=smart&auto=webp&s=a939922e34cf4ff6a82eeb22e71acb816ccc6d7b", + "width": 216, + "height": 121, + }, + { + "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?width=320&crop=smart&auto=webp&s=9796767ed73e04a774d2f1ba8cf3662bbd4195eb", + "width": 320, + "height": 180, + }, + { + "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?width=640&crop=smart&auto=webp&s=37fe4c262b752cb8dac903daf606be8f0ac3b44f", + "width": 640, + "height": 360, + }, + { + "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?width=960&crop=smart&auto=webp&s=305245fd1d352634c86459131b11238fe09f5d2b", + "width": 960, + "height": 540, + }, + { + "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?width=1080&crop=smart&auto=webp&s=e8438e4b666cf616646ffad09c153d120df1f1d9", + "width": 1080, + "height": 607, + }, + ], + "variants": {}, + "id": "SjRqA5h_B55WLnwAlocF6wcxIHZLgGBMpmb5nV1EQ4E", + } + ], + "enabled": True, + }, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "ca858044-1916-11e2-a9b9-12313d168e98", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": "new user/low karma", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2v94d", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#014980", + "id": "hpjn8x", + "is_robot_indexable": True, + "report_reasons": None, + "author": "clericanubis", + "discussion_type": None, + "num_comments": 27, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/starcitizen/comments/hpjn8x/this_view_whats_your_favorite_moon/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/ovly7f9g6ba51.jpg", + "subreddit_subscribers": 213071, + "created_utc": 1594508350, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + ], + "after": "t3_hplinp", + "before": None, + }, +} + +simple_mock_2 = { + "kind": "Listing", + "data": { + "modhash": "y4he8gfzh9f892e2bf3094bc06daba2e02288e617fecf555b5", + "dist": 27, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "Python", + "selftext": "Top Level comments must be **Job Opportunities.**\n\nPlease include **Location** or any other **Requirements** in your comment. If you require people to work on site in San Francisco, *you must note that in your post.* If you require an Engineering degree, *you must note that in your post*.\n\nPlease include as much information as possible.\n\nIf you are looking for jobs, send a PM to the poster.", + "author_fullname": "t2_628u", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "/r/Python Job Board for May, June, July", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/Python", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_gdfaip", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.98, + "author_flair_background_color": "", + "subreddit_type": "public", + "ups": 108, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 108, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": "", + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1588640187, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.Python", + "allow_live_comments": True, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>Top Level comments must be <strong>Job Opportunities.</strong></p>\n\n<p>Please include <strong>Location</strong> or any other <strong>Requirements</strong> in your comment. If you require people to work on site in San Francisco, <em>you must note that in your post.</em> If you require an Engineering degree, <em>you must note that in your post</em>.</p>\n\n<p>Please include as much information as possible.</p>\n\n<p>If you are looking for jobs, send a PM to the poster.</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": "reticulated", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh0y", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "gdfaip", + "is_robot_indexable": True, + "report_reasons": None, + "author": "aphoenix", + "discussion_type": None, + "num_comments": 38, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/Python/comments/gdfaip/rpython_job_board_for_may_june_july/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/Python/comments/gdfaip/rpython_job_board_for_may_june_july/", + "subreddit_subscribers": 616297, + "created_utc": 1588611387, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "Python", + "selftext": "# EDIT: AMA complete. Huge thanks to the PyCharm Team for holding this!\n\nAs mentioned in the comments you can use code `reddit20202` at [https://www.jetbrains.com/store/redeem/](https://www.jetbrains.com/store/redeem/) to try out PyCharm Professional as a new JetBrains customer!\n\nWe will be joined by members of the PyCharm Developer team from JetBrains to answer all sorts of questions on the PyCharm IDE and the Python language!\n\n[PyCharm](https://www.jetbrains.com/pycharm/) is the professional IDE for Python Developers with over 33% of respondents from the [2019 Python Developers Survey](https://www.jetbrains.com/lp/python-developers-survey-2019/) choosing it as their main editor.\n\nPyCharm features smart autocompletion, on-the-fly error checking and quick fixes as well as PEP8 compliance detection and automatic refactoring.\n\nIf you haven't checked out PyCharm then you definitely should, the Community Edition of PyCharm includes many key features such as the debugger, test runners, intelligent code completion and more!\n\nIf you are looking for a professional IDE for Python then the PyCharm Professional edition adds features such as advanced web development tools and database/SQL support, if you are a student or maintain an open source project make sure to take a look at the generous discounts JetBrains offer for their products!\n\nThe AMA will begin at 16:00 UTC on the 9th of July. Feel free to drop questions below for the PyCharm team to answer!\n\nWe will be joined by:\n\n* Nafiul Islam, u/nafiulislamjb (Developer Advocate for PyCharm)\n* Andrey Vlasovskikh, u/vlasovskikh (PyCharm Team Lead)", + "user_reports": [], + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "AMA with PyCharm team from JetBrains on 9th July @ 16:00 UTC", + "event_start": 1594310400, + "subreddit_name_prefixed": "r/Python", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "editors", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hmd2ez", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.94, + "author_flair_background_color": "", + "subreddit_type": "public", + "ups": 60, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "author_fullname": "t2_145f96", + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Editors / IDEs", + "can_mod_post": False, + "score": 60, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": 1594321779, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594088635, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.Python", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><h1>EDIT: AMA complete. Huge thanks to the PyCharm Team for holding this!</h1>\n\n<p>As mentioned in the comments you can use code <code>reddit20202</code> at <a href="https://www.jetbrains.com/store/redeem/">https://www.jetbrains.com/store/redeem/</a> to try out PyCharm Professional as a new JetBrains customer!</p>\n\n<p>We will be joined by members of the PyCharm Developer team from JetBrains to answer all sorts of questions on the PyCharm IDE and the Python language!</p>\n\n<p><a href="https://www.jetbrains.com/pycharm/">PyCharm</a> is the professional IDE for Python Developers with over 33% of respondents from the <a href="https://www.jetbrains.com/lp/python-developers-survey-2019/">2019 Python Developers Survey</a> choosing it as their main editor.</p>\n\n<p>PyCharm features smart autocompletion, on-the-fly error checking and quick fixes as well as PEP8 compliance detection and automatic refactoring.</p>\n\n<p>If you haven&#39;t checked out PyCharm then you definitely should, the Community Edition of PyCharm includes many key features such as the debugger, test runners, intelligent code completion and more!</p>\n\n<p>If you are looking for a professional IDE for Python then the PyCharm Professional edition adds features such as advanced web development tools and database/SQL support, if you are a student or maintain an open source project make sure to take a look at the generous discounts JetBrains offer for their products!</p>\n\n<p>The AMA will begin at 16:00 UTC on the 9th of July. Feel free to drop questions below for the PyCharm team to answer!</p>\n\n<p>We will be joined by:</p>\n\n<ul>\n<li>Nafiul Islam, <a href="/u/nafiulislamjb">u/nafiulislamjb</a> (Developer Advocate for PyCharm)</li>\n<li>Andrey Vlasovskikh, <a href="/u/vlasovskikh">u/vlasovskikh</a> (PyCharm Team Lead)</li>\n</ul>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": "confidence", + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "49f2747c-4114-11ea-b9fe-0e741fe75651", + "link_flair_richtext": [], + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": "Owner of Python Discord", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": "moderator", + "subreddit_id": "t5_2qh0y", + "event_end": 1594324800, + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "event_is_live": False, + "id": "hmd2ez", + "is_robot_indexable": True, + "report_reasons": None, + "author": "Im__Joseph", + "discussion_type": None, + "num_comments": 65, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/Python/comments/hmd2ez/ama_with_pycharm_team_from_jetbrains_on_9th_july/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/Python/comments/hmd2ez/ama_with_pycharm_team_from_jetbrains_on_9th_july/", + "subreddit_subscribers": 616297, + "created_utc": 1594059835, + "num_crossposts": 2, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "Python", + "selftext": "", + "author_fullname": "t2_woll6", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "I am a medical student, and I recently programmed an open-source eye-tracker for brain research", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/Python", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "made-this", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hpr28u", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.99, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 439, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": "4cc838b8-3159-11e1-83e4-12313d18ad57", + "is_original_content": False, + "user_reports": [], + "secure_media": { + "reddit_video": { + "fallback_url": "https://v.redd.it/tqzx750wzda51/DASH_360.mp4?source=fallback", + "height": 384, + "width": 512, + "scrubber_media_url": "https://v.redd.it/tqzx750wzda51/DASH_96.mp4", + "dash_url": "https://v.redd.it/tqzx750wzda51/DASHPlaylist.mpd?a=1597142191%2CY2JkNmU5Y2FmZGM1NzA5MjhkYTk5NjdmMWRmNWI4M2I2N2Q2MjA5NmIzZWRmODJiMjk0MzY4OTZlYTBiZmZlZg%3D%3D&v=1&f=sd", + "duration": 31, + "hls_url": "https://v.redd.it/tqzx750wzda51/HLSPlaylist.m3u8?a=1597142191%2CZDVhNWNjMGQ0OTBjOTU0Zjk5MDgwZmE2YzA1MGY5YzNlZThmZTAxZTgxODIxMGFjZDdlYzczOWFlYTcyMmMzNg%3D%3D&v=1&f=sd", + "is_gif": False, + "transcoding_status": "completed", + } + }, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "I Made This", + "can_mod_post": False, + "score": 439, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594571350, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "v.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://v.redd.it/tqzx750wzda51", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "d7dfae22-4113-11ea-b9fe-0e741fe75651", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": "Neuroscientist", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh0y", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hpr28u", + "is_robot_indexable": True, + "report_reasons": None, + "author": "Sebaron", + "discussion_type": None, + "num_comments": 33, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/Python/comments/hpr28u/i_am_a_medical_student_and_i_recently_programmed/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://v.redd.it/tqzx750wzda51", + "subreddit_subscribers": 616297, + "created_utc": 1594542550, + "num_crossposts": 0, + "media": { + "reddit_video": { + "fallback_url": "https://v.redd.it/tqzx750wzda51/DASH_360.mp4?source=fallback", + "height": 384, + "width": 512, + "scrubber_media_url": "https://v.redd.it/tqzx750wzda51/DASH_96.mp4", + "dash_url": "https://v.redd.it/tqzx750wzda51/DASHPlaylist.mpd?a=1597142191%2CY2JkNmU5Y2FmZGM1NzA5MjhkYTk5NjdmMWRmNWI4M2I2N2Q2MjA5NmIzZWRmODJiMjk0MzY4OTZlYTBiZmZlZg%3D%3D&v=1&f=sd", + "duration": 31, + "hls_url": "https://v.redd.it/tqzx750wzda51/HLSPlaylist.m3u8?a=1597142191%2CZDVhNWNjMGQ0OTBjOTU0Zjk5MDgwZmE2YzA1MGY5YzNlZThmZTAxZTgxODIxMGFjZDdlYzczOWFlYTcyMmMzNg%3D%3D&v=1&f=sd", + "is_gif": False, + "transcoding_status": "completed", + } + }, + "is_video": True, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "Python", + "selftext": "", + "author_fullname": "t2_6zgzj94n", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "I made a filename simplifier which removes unnecessary tags, metadata, dashes, dots, underscores, and non-English characters from filenames (and folders) to give your library a neat look.", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/Python", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "made-this", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hpps6f", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.95, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 258, + "total_awards_received": 1, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": { + "reddit_video": { + "fallback_url": "https://v.redd.it/jq229anzada51/DASH_1080.mp4?source=fallback", + "height": 1080, + "width": 1920, + "scrubber_media_url": "https://v.redd.it/jq229anzada51/DASH_96.mp4", + "dash_url": "https://v.redd.it/jq229anzada51/DASHPlaylist.mpd?a=1597142191%2CZDU4Y2FmYzI2NjMzZTMxNzJkOThiMzJmYzBlOTMyMmEwNTg3MTFhMmU0OWZjZDljZGQ4MjAwMTgxMGVhYzU1OQ%3D%3D&v=1&f=sd", + "duration": 27, + "hls_url": "https://v.redd.it/jq229anzada51/HLSPlaylist.m3u8?a=1597142191%2CYmY1Y2Q5ZjQ0ZWVmODAxODQ3MGU3YzA1YzIxOTEzODFlNWQyMjE4MzAyYzNiMDM5NTI0N2M5OTRmY2YwN2NlOA%3D%3D&v=1&f=sd", + "is_gif": False, + "transcoding_status": "completed", + } + }, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "I Made This", + "can_mod_post": False, + "score": 258, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594563987, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "v.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://v.redd.it/jq229anzada51", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [ + { + "giver_coin_reward": 0, + "subreddit_id": None, + "is_new": False, + "days_of_drip_extension": 0, + "coin_price": 75, + "id": "award_9663243a-e77f-44cf-abc6-850ead2cd18d", + "penny_donate": 0, + "award_sub_type": "PREMIUM", + "coin_reward": 0, + "icon_url": "https://www.redditstatic.com/gold/awards/icon/SnooClappingPremium_512.png", + "days_of_premium": 0, + "resized_icons": [ + { + "url": "https://www.redditstatic.com/gold/awards/icon/SnooClappingPremium_16.png", + "width": 16, + "height": 16, + }, + { + "url": "https://www.redditstatic.com/gold/awards/icon/SnooClappingPremium_32.png", + "width": 32, + "height": 32, + }, + { + "url": "https://www.redditstatic.com/gold/awards/icon/SnooClappingPremium_48.png", + "width": 48, + "height": 48, + }, + { + "url": "https://www.redditstatic.com/gold/awards/icon/SnooClappingPremium_64.png", + "width": 64, + "height": 64, + }, + { + "url": "https://www.redditstatic.com/gold/awards/icon/SnooClappingPremium_128.png", + "width": 128, + "height": 128, + }, + ], + "icon_width": 512, + "static_icon_width": 512, + "start_date": None, + "is_enabled": True, + "description": "For an especially amazing showing.", + "end_date": None, + "subreddit_coin_reward": 0, + "count": 1, + "static_icon_height": 512, + "name": "Bravo Grande!", + "resized_static_icons": [ + { + "url": "https://preview.redd.it/award_images/t5_q0gj4/59e02tmkl4451_BravoGrande-Static.png?width=16&height=16&auto=webp&s=3459bdf1d1777821a831c5bf9834f4365263fcff", + "width": 16, + "height": 16, + }, + { + "url": "https://preview.redd.it/award_images/t5_q0gj4/59e02tmkl4451_BravoGrande-Static.png?width=32&height=32&auto=webp&s=9181d68065ccfccf2b1074e499cd7c1103aa2ce8", + "width": 32, + "height": 32, + }, + { + "url": "https://preview.redd.it/award_images/t5_q0gj4/59e02tmkl4451_BravoGrande-Static.png?width=48&height=48&auto=webp&s=339b368d395219120abc50d54fb3e2cdcad8ca4f", + "width": 48, + "height": 48, + }, + { + "url": "https://preview.redd.it/award_images/t5_q0gj4/59e02tmkl4451_BravoGrande-Static.png?width=64&height=64&auto=webp&s=de4ebbe92f9019de05aaa77f88810d44adbe1e50", + "width": 64, + "height": 64, + }, + { + "url": "https://preview.redd.it/award_images/t5_q0gj4/59e02tmkl4451_BravoGrande-Static.png?width=128&height=128&auto=webp&s=ba6c1add5204ea43e5af010bd9622392a42140e3", + "width": 128, + "height": 128, + }, + ], + "icon_format": "APNG", + "icon_height": 512, + "penny_price": 0, + "award_type": "global", + "static_icon_url": "https://i.redd.it/award_images/t5_q0gj4/59e02tmkl4451_BravoGrande-Static.png", + } + ], + "awarders": [], + "media_only": False, + "link_flair_template_id": "d7dfae22-4113-11ea-b9fe-0e741fe75651", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh0y", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hpps6f", + "is_robot_indexable": True, + "report_reasons": None, + "author": "Hobo-TheGodOfPoverty", + "discussion_type": None, + "num_comments": 25, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/Python/comments/hpps6f/i_made_a_filename_simplifier_which_removes/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://v.redd.it/jq229anzada51", + "subreddit_subscribers": 616297, + "created_utc": 1594535187, + "num_crossposts": 0, + "media": { + "reddit_video": { + "fallback_url": "https://v.redd.it/jq229anzada51/DASH_1080.mp4?source=fallback", + "height": 1080, + "width": 1920, + "scrubber_media_url": "https://v.redd.it/jq229anzada51/DASH_96.mp4", + "dash_url": "https://v.redd.it/jq229anzada51/DASHPlaylist.mpd?a=1597142191%2CZDU4Y2FmYzI2NjMzZTMxNzJkOThiMzJmYzBlOTMyMmEwNTg3MTFhMmU0OWZjZDljZGQ4MjAwMTgxMGVhYzU1OQ%3D%3D&v=1&f=sd", + "duration": 27, + "hls_url": "https://v.redd.it/jq229anzada51/HLSPlaylist.m3u8?a=1597142191%2CYmY1Y2Q5ZjQ0ZWVmODAxODQ3MGU3YzA1YzIxOTEzODFlNWQyMjE4MzAyYzNiMDM5NTI0N2M5OTRmY2YwN2NlOA%3D%3D&v=1&f=sd", + "is_gif": False, + "transcoding_status": "completed", + } + }, + "is_video": True, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "Python", + "selftext": "", + "author_fullname": "t2_1kjpn251", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Concept Art: what might python look like in Japanese, without any English characters?", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/Python", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "discussion", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hp7uqe", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.94, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 1697, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Discussion", + "can_mod_post": False, + "score": 1697, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [ + { + "approved_at_utc": None, + "subreddit": "ProgrammingLanguages", + "selftext": "", + "author_fullname": "t2_f4rdtgk", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Concept Art: what might python look like in Japanese, without any English characters?", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/ProgrammingLanguages", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_g9iu8x", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.96, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 440, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Discussion", + "can_mod_post": False, + "score": 440, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1588088407, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/ulc23n21jiv41.png", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "93811e06-0da7-11e8-a9a2-0e1129ea8e52", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qi8m", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "g9iu8x", + "is_robot_indexable": True, + "report_reasons": None, + "author": "MartialArtTetherball", + "discussion_type": None, + "num_comments": 65, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/ProgrammingLanguages/comments/g9iu8x/concept_art_what_might_python_look_like_in/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/ulc23n21jiv41.png", + "subreddit_subscribers": 43859, + "created_utc": 1588059607, + "num_crossposts": 2, + "media": None, + "is_video": False, + } + ], + "created": 1594492194, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/ulc23n21jiv41.png", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "0df42996-1c5e-11ea-b1a0-0e44e1c5b731", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh0y", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hp7uqe", + "is_robot_indexable": True, + "report_reasons": None, + "author": "SubstantialRange", + "discussion_type": None, + "num_comments": 182, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_g9iu8x", + "author_flair_text_color": None, + "permalink": "/r/Python/comments/hp7uqe/concept_art_what_might_python_look_like_in/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/ulc23n21jiv41.png", + "subreddit_subscribers": 616297, + "created_utc": 1594463394, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + ], + "after": "t3_hozdzo", + "before": None, + }, +} + +empty_mock = { + "kind": "Listing", + "data": { + "modhash": "y4he8gfzh9f892e2bf3094bc06daba2e02288e617fecf555b5", + "dist": 27, + "children": [], + "after": "t3_hozdzo", + "before": None, + }, +} diff --git a/src/newsreader/news/collection/tests/reddit/collector/tests.py b/src/newsreader/news/collection/tests/reddit/collector/tests.py new file mode 100644 index 0000000..1fd18b0 --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/collector/tests.py @@ -0,0 +1,204 @@ +from datetime import datetime +from unittest.mock import patch +from uuid import uuid4 + +from django.test import TestCase +from django.utils import timezone + +import pytz + +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamForbiddenException, + StreamNotFoundException, + StreamTimeOutException, +) +from newsreader.news.collection.reddit import RedditCollector +from newsreader.news.collection.tests.factories import SubredditFactory +from newsreader.news.collection.tests.reddit.collector.mocks import ( + empty_mock, + simple_mock_1, + simple_mock_2, +) +from newsreader.news.core.models import Post + + +class RedditCollectorTestCase(TestCase): + def setUp(self): + self.maxDiff = None + + self.patched_get = patch("newsreader.news.collection.reddit.fetch") + self.mocked_fetch = self.patched_get.start() + + self.patched_parse = patch( + "newsreader.news.collection.reddit.RedditStream.parse" + ) + self.mocked_parse = self.patched_parse.start() + + def tearDown(self): + patch.stopall() + + def test_simple_batch(self): + self.mocked_parse.side_effect = (simple_mock_1, simple_mock_2) + + rules = ( + (subreddit,) + for subreddit in SubredditFactory.create_batch( + user__reddit_access_token=str(uuid4()), + user__reddit_refresh_token=str(uuid4()), + enabled=True, + size=2, + ) + ) + + collector = RedditCollector() + collector.collect(rules=rules) + + self.assertCountEqual( + Post.objects.values_list("remote_identifier", flat=True), + ( + "hm6byg", + "hpkhgj", + "hph00n", + "hp9mlw", + "hpjn8x", + "gdfaip", + "hmd2ez", + "hpr28u", + "hpps6f", + "hp7uqe", + ), + ) + + for subreddit in rules: + with self.subTest(subreddit=subreddit): + self.assertEquals(subreddit.succeeded, True) + self.assertEquals(subreddit.last_suceeded, timezone.now()) + self.assertEquals(subreddit.error, None) + + post = Post.objects.get( + remote_identifier="hph00n", rule__type=RuleTypeChoices.subreddit + ) + + self.assertEquals( + post.publication_date, pytz.utc.localize(datetime(2020, 7, 11, 22, 23, 24)) + ) + + self.assertEquals(post.author, "HannahB888") + self.assertEquals( + post.title, "Drake Interplanetary Smartkey thing that I made!" + ) + self.assertEquals( + post.url, + "https://www.reddit.com/r/starcitizen/comments/hph00n/drake_interplanetary_smartkey_thing_that_i_made/", + ) + + post = Post.objects.get( + remote_identifier="hpr28u", rule__type=RuleTypeChoices.subreddit + ) + + self.assertEquals( + post.publication_date, pytz.utc.localize(datetime(2020, 7, 12, 10, 29, 10)) + ) + + self.assertEquals(post.author, "Sebaron") + self.assertEquals( + post.title, + "I am a medical student, and I recently programmed an open-source eye-tracker for brain research", + ) + self.assertEquals( + post.url, + "https://www.reddit.com/r/Python/comments/hpr28u/i_am_a_medical_student_and_i_recently_programmed/", + ) + + def test_empty_batch(self): + self.mocked_parse.side_effect = (empty_mock, empty_mock) + + rules = ( + (subreddit,) + for subreddit in SubredditFactory.create_batch( + user__reddit_access_token=str(uuid4()), + user__reddit_refresh_token=str(uuid4()), + enabled=True, + size=2, + ) + ) + + collector = RedditCollector() + collector.collect(rules=rules) + + self.assertEquals(Post.objects.count(), 0) + + for subreddit in rules: + with self.subTest(subreddit=subreddit): + self.assertEquals(subreddit.succeeded, True) + self.assertEquals(subreddit.last_suceeded, timezone.now()) + self.assertEquals(subreddit.error, None) + + def test_not_found(self): + self.mocked_fetch.side_effect = StreamNotFoundException + + rule = SubredditFactory( + user__reddit_access_token=str(uuid4()), + user__reddit_refresh_token=str(uuid4()), + enabled=True, + ) + + collector = RedditCollector() + collector.collect(rules=((rule,),)) + + self.assertEquals(Post.objects.count(), 0) + self.assertEquals(rule.succeeded, False) + self.assertEquals(rule.error, "Stream not found") + + @patch("newsreader.news.collection.reddit.RedditTokenTask") + def test_denied(self, mocked_task): + self.mocked_fetch.side_effect = StreamDeniedException + + rule = SubredditFactory( + user__reddit_access_token=str(uuid4()), + user__reddit_refresh_token=str(uuid4()), + enabled=True, + ) + + collector = RedditCollector() + collector.collect(rules=((rule,),)) + + self.assertEquals(Post.objects.count(), 0) + self.assertEquals(rule.succeeded, False) + self.assertEquals(rule.error, "Stream does not have sufficient permissions") + + mocked_task.delay.assert_called_once_with(rule.user.pk) + + def test_forbidden(self): + self.mocked_fetch.side_effect = StreamForbiddenException + + rule = SubredditFactory( + user__reddit_access_token=str(uuid4()), + user__reddit_refresh_token=str(uuid4()), + enabled=True, + ) + + collector = RedditCollector() + collector.collect(rules=((rule,),)) + + self.assertEquals(Post.objects.count(), 0) + self.assertEquals(rule.succeeded, False) + self.assertEquals(rule.error, "Stream forbidden") + + def test_timed_out(self): + self.mocked_fetch.side_effect = StreamTimeOutException + + rule = SubredditFactory( + user__reddit_access_token=str(uuid4()), + user__reddit_refresh_token=str(uuid4()), + enabled=True, + ) + + collector = RedditCollector() + collector.collect(rules=((rule,),)) + + self.assertEquals(Post.objects.count(), 0) + self.assertEquals(rule.succeeded, False) + self.assertEquals(rule.error, "Stream timed out") diff --git a/src/newsreader/news/collection/tests/reddit/stream/__init__.py b/src/newsreader/news/collection/tests/reddit/stream/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/reddit/stream/mocks.py b/src/newsreader/news/collection/tests/reddit/stream/mocks.py new file mode 100644 index 0000000..148b31a --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/stream/mocks.py @@ -0,0 +1,3289 @@ +simple_mock = { + "kind": "Listing", + "data": { + "modhash": "sgq4fdizx94db5c05b57f9957a4b8b2d5e24b712f5a507cffd", + "dist": 27, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "Welcome to r/linux rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.\n\nLet us know what's annoying you, whats making you happy, or something that you want to get out to r/linux but didn't make the cut into a full post of it's own.\n\nFor those looking for certifications please use this megathread to ask about how to get certified whether it's for the business world or for your own satisfaction. Be sure to check out r/linuxadmin for more discussion in the SysAdmin world!\n\n_Please keep questions in r/linuxquestions, r/linux4noobs, or the Wednesday automod thread._", + "author_fullname": "t2_6l4z3", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hm0qct", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.65, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 6, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 6, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594037482.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>Welcome to <a href="/r/linux">r/linux</a> rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.</p>\n\n<p>Let us know what&#39;s annoying you, whats making you happy, or something that you want to get out to <a href="/r/linux">r/linux</a> but didn&#39;t make the cut into a full post of it&#39;s own.</p>\n\n<p>For those looking for certifications please use this megathread to ask about how to get certified whether it&#39;s for the business world or for your own satisfaction. Be sure to check out <a href="/r/linuxadmin">r/linuxadmin</a> for more discussion in the SysAdmin world!</p>\n\n<p><em>Please keep questions in <a href="/r/linuxquestions">r/linuxquestions</a>, <a href="/r/linux4noobs">r/linux4noobs</a>, or the Wednesday automod thread.</em></p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": "moderator", + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hm0qct", + "is_robot_indexable": True, + "report_reasons": None, + "author": "AutoModerator", + "discussion_type": None, + "num_comments": 8, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", + "subreddit_subscribers": 543995, + "created_utc": 1594008682.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "Welcome to r/linux! If you're new to Linux or trying to get started this thread is for you. Get help here or as always, check out r/linuxquestions or r/linux4noobs\n\nThis megathread is for all your question needs. As we don't allow questions on r/linux outside of this megathread, please consider using r/linuxquestions or r/linux4noobs for the best solution to your problem.\n\nAsk your hardware requests here too or try r/linuxhardware!", + "author_fullname": "t2_6l4z3", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Weekly Questions and Hardware Thread - July 08, 2020", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hna75r", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.5, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 0, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 0, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594210138.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>Welcome to <a href="/r/linux">r/linux</a>! If you&#39;re new to Linux or trying to get started this thread is for you. Get help here or as always, check out <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a></p>\n\n<p>This megathread is for all your question needs. As we don&#39;t allow questions on <a href="/r/linux">r/linux</a> outside of this megathread, please consider using <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a> for the best solution to your problem.</p>\n\n<p>Ask your hardware requests here too or try <a href="/r/linuxhardware">r/linuxhardware</a>!</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": "new", + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": "moderator", + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hna75r", + "is_robot_indexable": True, + "report_reasons": None, + "author": "AutoModerator", + "discussion_type": None, + "num_comments": 2, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", + "subreddit_subscribers": 543995, + "created_utc": 1594181338.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_gr7k5", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Here's a feature Linux could borrow from BSD: in-kernel debugger with built-in hangman game", + "link_flair_richtext": [{"e": "text", "t": "Fluff"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hngs71", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.9, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 135, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Fluff", + "can_mod_post": False, + "score": 135, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594242629.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/wmc8tp2ium951.jpg", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "af8918be-6777-11e7-8273-0e925d908786", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#9a2bff", + "id": "hngs71", + "is_robot_indexable": True, + "report_reasons": None, + "author": "the_humeister", + "discussion_type": None, + "num_comments": 20, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hngs71/heres_a_feature_linux_could_borrow_from_bsd/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/wmc8tp2ium951.jpg", + "subreddit_subscribers": 543995, + "created_utc": 1594213829.0, + "num_crossposts": 1, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_k9f35", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "KeePassXC 2.6.0 released", + "link_flair_richtext": [{"e": "text", "t": "Software Release"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hngsj8", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.97, + "author_flair_background_color": "transparent", + "subreddit_type": "public", + "ups": 126, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": "fa602e36-cdf6-11e8-93c9-0e41ac35f4cc", + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Software Release", + "can_mod_post": False, + "score": 126, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [ + { + "a": ":ubuntu:", + "e": "emoji", + "u": "https://emoji.redditmedia.com/uwmddx7qqpr11_t5_2qh1a/ubuntu", + } + ], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594242666.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "keepassxc.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://keepassxc.org/blog/2020-07-07-2.6.0-released/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "904ea3e4-6748-11e7-b925-0ef3dfbb807a", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": ":ubuntu:", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#349e48", + "id": "hngsj8", + "is_robot_indexable": True, + "report_reasons": None, + "author": "nixcraft", + "discussion_type": None, + "num_comments": 42, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/linux/comments/hngsj8/keepassxc_260_released/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://keepassxc.org/blog/2020-07-07-2.6.0-released/", + "subreddit_subscribers": 543995, + "created_utc": 1594213866.0, + "num_crossposts": 1, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [{"e": "text", "t": "Popular Application"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd7cy", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.95, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 223, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Popular Application", + "can_mod_post": False, + "score": 223, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [ + { + "approved_at_utc": None, + "subreddit": "libreoffice", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/libreoffice", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd6yo", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.94, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 28, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "News", + "can_mod_post": False, + "score": 28, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594224961.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "dc82ac98-bafb-11e4-9f88-22000b310327", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2s4nt", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnd6yo", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZero", + "discussion_type": None, + "num_comments": 38, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/libreoffice/comments/hnd6yo/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 4669, + "created_utc": 1594196161.0, + "num_crossposts": 2, + "media": None, + "is_video": False, + } + ], + "created": 1594225018.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "7127ec98-5859-11e8-9488-0e8717893ec8", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#0aa18f", + "id": "hnd7cy", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZero", + "discussion_type": None, + "num_comments": 109, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_hnd6yo", + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnd7cy/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 543995, + "created_utc": 1594196218.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_6cxnzaq2", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Gentoo Now on Android Platform !!!", + "link_flair_richtext": [{"e": "text", "t": "Mobile Linux"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnemei", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.87, + "author_flair_background_color": "transparent", + "subreddit_type": "public", + "ups": 78, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": "a54a7460-cdf6-11e8-b31c-0e89679a2148", + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Mobile Linux", + "can_mod_post": False, + "score": 78, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [ + { + "a": ":arch:", + "e": "emoji", + "u": "https://emoji.redditmedia.com/tip79drnqpr11_t5_2qh1a/arch", + } + ], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594232773.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "gentoo.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://www.gentoo.org/news/2020/07/07/gentoo-android.html", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "84162644-5859-11e8-b9ed-0efda312d094", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": ":arch:", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#d78216", + "id": "hnemei", + "is_robot_indexable": True, + "report_reasons": None, + "author": "draplon", + "discussion_type": None, + "num_comments": 21, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/linux/comments/hnemei/gentoo_now_on_android_platform/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.gentoo.org/news/2020/07/07/gentoo-android.html", + "subreddit_subscribers": 543995, + "created_utc": 1594203973.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_f9vxe", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Google is teaming up with Ubuntu to bring Flutter apps to Linux", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hniojf", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.77, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 31, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 31, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594249580.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "androidpolice.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://www.androidpolice.com/2020/07/08/google-is-teaming-up-with-ubuntu-to-bring-flutter-apps-to-linux/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hniojf", + "is_robot_indexable": True, + "report_reasons": None, + "author": "bilal4hmed", + "discussion_type": None, + "num_comments": 24, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hniojf/google_is_teaming_up_with_ubuntu_to_bring_flutter/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.androidpolice.com/2020/07/08/google-is-teaming-up-with-ubuntu-to-bring-flutter-apps-to-linux/", + "subreddit_subscribers": 543995, + "created_utc": 1594220780.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_k9f35", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Ariane RISC-V CPU \u2013 An open source CPU capable of booting Linux", + "link_flair_richtext": [{"e": "text", "t": "Hardware"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hngr1j", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.89, + "author_flair_background_color": "transparent", + "subreddit_type": "public", + "ups": 49, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": "fa602e36-cdf6-11e8-93c9-0e41ac35f4cc", + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Hardware", + "can_mod_post": False, + "score": 49, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [ + { + "a": ":ubuntu:", + "e": "emoji", + "u": "https://emoji.redditmedia.com/uwmddx7qqpr11_t5_2qh1a/ubuntu", + } + ], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594242511.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "github.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://github.com/openhwgroup/cva6", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "3d48793a-c823-11e8-9a58-0ee3c97eb952", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": ":ubuntu:", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#cc5289", + "id": "hngr1j", + "is_robot_indexable": True, + "report_reasons": None, + "author": "nixcraft", + "discussion_type": None, + "num_comments": 15, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/linux/comments/hngr1j/ariane_riscv_cpu_an_open_source_cpu_capable_of/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://github.com/openhwgroup/cva6", + "subreddit_subscribers": 543995, + "created_utc": 1594213711.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_6kt9ukjs", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Canonical enables Linux desktop app support with Flutter", + "link_flair_richtext": [{"e": "text", "t": "Software Release"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnj1ap", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.79, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 24, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Software Release", + "can_mod_post": False, + "score": 24, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594250752.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "ubuntu.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://ubuntu.com/blog/canonical-enables-linux-desktop-app-support-with-flutter", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "904ea3e4-6748-11e7-b925-0ef3dfbb807a", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#349e48", + "id": "hnj1ap", + "is_robot_indexable": True, + "report_reasons": None, + "author": "hmblhstl", + "discussion_type": None, + "num_comments": 28, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnj1ap/canonical_enables_linux_desktop_app_support_with/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://ubuntu.com/blog/canonical-enables-linux-desktop-app-support-with-flutter", + "subreddit_subscribers": 543995, + "created_utc": 1594221952.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_3vf8x", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Sandboxing in Linux with zero lines of code", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnfzbm", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.83, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 30, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 30, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594239285.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.cloudflare.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.cloudflare.com/sandboxing-in-linux-with-zero-lines-of-code/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnfzbm", + "is_robot_indexable": True, + "report_reasons": None, + "author": "pimterry", + "discussion_type": None, + "num_comments": 0, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnfzbm/sandboxing_in_linux_with_zero_lines_of_code/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.cloudflare.com/sandboxing-in-linux-with-zero-lines-of-code/", + "subreddit_subscribers": 543995, + "created_utc": 1594210485.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_318in", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "SUSE Enters Into Definitive Agreement to Acquire Rancher Labs", + "link_flair_richtext": [ + {"e": "text", "t": "Open Source Organization"} + ], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnh5ux", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.84, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 26, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Open Source Organization", + "can_mod_post": False, + "score": 26, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594244123.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "rancher.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://rancher.com/blog/2020/suse-to-acquire-rancher/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "8a1dd4b0-5859-11e8-a2c7-0e5ebdbe24d6", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#800000", + "id": "hnh5ux", + "is_robot_indexable": True, + "report_reasons": None, + "author": "hjames9", + "discussion_type": None, + "num_comments": 5, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnh5ux/suse_enters_into_definitive_agreement_to_acquire/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://rancher.com/blog/2020/suse-to-acquire-rancher/", + "subreddit_subscribers": 543995, + "created_utc": 1594215323.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_j1a5", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Linux Mint drops Ubuntu Snap packages [LWN.net]", + "link_flair_richtext": [{"e": "text", "t": "Distro News"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": True, + "name": "t3_hnlt4l", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.8, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 9, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Distro News", + "can_mod_post": False, + "score": 9, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594259641.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "lwn.net", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://lwn.net/SubscriberLink/825005/6440c82feb745bbe/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "6888e772-5859-11e8-82ff-0e816ab71260", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#0dd3bb", + "id": "hnlt4l", + "is_robot_indexable": True, + "report_reasons": None, + "author": "tapo", + "discussion_type": None, + "num_comments": 3, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnlt4l/linux_mint_drops_ubuntu_snap_packages_lwnnet/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://lwn.net/SubscriberLink/825005/6440c82feb745bbe/", + "subreddit_subscribers": 543995, + "created_utc": 1594230841.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_4i3yk", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Announcing Flutter Linux Alpha with Canonical", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hniq04", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.6, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 6, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 6, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594249712.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "medium.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://medium.com/flutter/announcing-flutter-linux-alpha-with-canonical-19eb824590a9", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hniq04", + "is_robot_indexable": True, + "report_reasons": None, + "author": "popeydc", + "discussion_type": None, + "num_comments": 3, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hniq04/announcing_flutter_linux_alpha_with_canonical/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://medium.com/flutter/announcing-flutter-linux-alpha-with-canonical-19eb824590a9", + "subreddit_subscribers": 543995, + "created_utc": 1594220912.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_611c0ard", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "New anti-encryption bill worse than EARN IT, would force a backdoor into any US device/software. Act now to stop both.", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hmp66i", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.98, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 3340, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 3340, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594131589.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "tutanota.com", + "allow_live_comments": True, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://tutanota.com/blog/posts/lawful-access-encrypted-data-act-backdoor", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hmp66i", + "is_robot_indexable": True, + "report_reasons": None, + "author": "fossfans", + "discussion_type": None, + "num_comments": 380, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hmp66i/new_antiencryption_bill_worse_than_earn_it_would/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://tutanota.com/blog/posts/lawful-access-encrypted-data-act-backdoor", + "subreddit_subscribers": 543995, + "created_utc": 1594102789.0, + "num_crossposts": 2, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "We have had Freesync \"support\" for quite some time now, but it is extremely restrictive and very picky to get it working. Just the requirements to have Freesync working is no-go for many:\n\n\\-> Single monitor only;\n\n\\-> No video playback or turning it on while on desktop;\n\n\\-> Should only be turned on only while the game/software in question is in fullscreen;\n\n\\-> X11, no Wayland;\n\n\\-> Only tested/working distro is Ubuntu 16.04.3;\n\n\\-> Need of setting it up through some quite cryptic commands;\n\n\\-> Doesn't work after hotplug or system restart;\n\n\\-> No Freesync over HDMI (which isn't a massive problem, but a nice option to have);\n\n\\-> Apparently only OpenGL, no Vulkan (Steam Play/Proton, which is the main purpose for Freesync at the moment, doesn't work);\n\n&#x200B;\n\nI am not really complaining, because I do know that Freesync is hard to get working on Linux, but we have had so many advancements on the gaming side of Linux, and we are still stuck with all of these restrictions to use Freesync, which is quite a useful functionality for almost every gamer. If Mozilla got video decoding working well on Wayland, I hope (Idk anything about this, just hoping) that it could also be easy to implement Freesync on Wayland too.\n\nWe just haven't had that many improvements on this side of the Linux gaming world, and I'd like to know if it is lack of support/interest by AMD, or if it actually is extremely hard to implement it on Linux. Freesync would also be very useful for those who are running monitors over 60Hz, so that those 60FPS videos don't look as weird as they do while playing back on higher refresh rate monitors. It is just a nice thing for everybody, really!", + "author_fullname": "t2_1afv9v8g", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Any evolution on the Freesync situation on Linux?", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hn7agp", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.85, + "author_flair_background_color": "transparent", + "subreddit_type": "public", + "ups": 83, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": "fa602e36-cdf6-11e8-93c9-0e41ac35f4cc", + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 83, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [ + { + "a": ":ubuntu:", + "e": "emoji", + "u": "https://emoji.redditmedia.com/uwmddx7qqpr11_t5_2qh1a/ubuntu", + } + ], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594199056.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>We have had Freesync &quot;support&quot; for quite some time now, but it is extremely restrictive and very picky to get it working. Just the requirements to have Freesync working is no-go for many:</p>\n\n<p>-&gt; Single monitor only;</p>\n\n<p>-&gt; No video playback or turning it on while on desktop;</p>\n\n<p>-&gt; Should only be turned on only while the game/software in question is in fullscreen;</p>\n\n<p>-&gt; X11, no Wayland;</p>\n\n<p>-&gt; Only tested/working distro is Ubuntu 16.04.3;</p>\n\n<p>-&gt; Need of setting it up through some quite cryptic commands;</p>\n\n<p>-&gt; Doesn&#39;t work after hotplug or system restart;</p>\n\n<p>-&gt; No Freesync over HDMI (which isn&#39;t a massive problem, but a nice option to have);</p>\n\n<p>-&gt; Apparently only OpenGL, no Vulkan (Steam Play/Proton, which is the main purpose for Freesync at the moment, doesn&#39;t work);</p>\n\n<p>&#x200B;</p>\n\n<p>I am not really complaining, because I do know that Freesync is hard to get working on Linux, but we have had so many advancements on the gaming side of Linux, and we are still stuck with all of these restrictions to use Freesync, which is quite a useful functionality for almost every gamer. If Mozilla got video decoding working well on Wayland, I hope (Idk anything about this, just hoping) that it could also be easy to implement Freesync on Wayland too.</p>\n\n<p>We just haven&#39;t had that many improvements on this side of the Linux gaming world, and I&#39;d like to know if it is lack of support/interest by AMD, or if it actually is extremely hard to implement it on Linux. Freesync would also be very useful for those who are running monitors over 60Hz, so that those 60FPS videos don&#39;t look as weird as they do while playing back on higher refresh rate monitors. It is just a nice thing for everybody, really!</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": ":ubuntu:", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hn7agp", + "is_robot_indexable": True, + "report_reasons": None, + "author": "mreich98", + "discussion_type": None, + "num_comments": 36, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/linux/comments/hn7agp/any_evolution_on_the_freesync_situation_on_linux/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.reddit.com/r/linux/comments/hn7agp/any_evolution_on_the_freesync_situation_on_linux/", + "subreddit_subscribers": 543995, + "created_utc": 1594170256.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_7ccf", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Running Rosetta@home on a Raspberry Pi with Fedora IoT", + "link_flair_richtext": [{"e": "text", "t": "Popular Application"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnfw0h", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.73, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 8, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Popular Application", + "can_mod_post": False, + "score": 8, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594238884.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "fedoramagazine.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://fedoramagazine.org/running-rosettahome-on-a-raspberry-pi-with-fedora-iot/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "7127ec98-5859-11e8-9488-0e8717893ec8", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#0aa18f", + "id": "hnfw0h", + "is_robot_indexable": True, + "report_reasons": None, + "author": "speckz", + "discussion_type": None, + "num_comments": 1, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnfw0h/running_rosettahome_on_a_raspberry_pi_with_fedora/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://fedoramagazine.org/running-rosettahome-on-a-raspberry-pi-with-fedora-iot/", + "subreddit_subscribers": 543995, + "created_utc": 1594210084.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_sx11s", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Getting Things GNOME 0.4 released! First release in almost 7 years (Flatpak available).", + "link_flair_richtext": [{"e": "text", "t": "Software Release"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hn5wh6", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.79, + "author_flair_background_color": "transparent", + "subreddit_type": "public", + "ups": 58, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": "2194c338-ce1d-11e8-8ed7-0e20bb1bbc52", + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Software Release", + "can_mod_post": False, + "score": 58, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [ + { + "a": ":nix:", + "e": "emoji", + "u": "https://emoji.redditmedia.com/ww1ubcjpqpr11_t5_2qh1a/nix", + } + ], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594193982.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "flathub.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://flathub.org/apps/details/org.gnome.GTG", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "904ea3e4-6748-11e7-b925-0ef3dfbb807a", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": ":nix:", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#349e48", + "id": "hn5wh6", + "is_robot_indexable": True, + "report_reasons": None, + "author": "Kanarme", + "discussion_type": None, + "num_comments": 22, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/linux/comments/hn5wh6/getting_things_gnome_04_released_first_release_in/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://flathub.org/apps/details/org.gnome.GTG", + "subreddit_subscribers": 543995, + "created_utc": 1594165182.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_636xx258", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "mpv is not anymore supporting gnome. and the owner reverted the commit again shortly after and then again made a new one, to add the changes", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": True, + "name": "t3_hnnt0v", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 1.0, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 1, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 1, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [ + { + "approved_at_utc": None, + "subreddit": "gnome", + "selftext": "", + "author_fullname": "t2_33wgs4m3", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "mpv is not anymore supporting gnome. and the owner reverted the commit again shortly after and then again made a new one, to add the changes", + "link_flair_richtext": [{"e": "text", "t": "News"}], + "subreddit_name_prefixed": "r/gnome", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hn1s3r", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.81, + "author_flair_background_color": "transparent", + "subreddit_type": "public", + "ups": 23, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": "1515012e-bed8-11ea-92a7-0eb4e155a177", + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "News", + "can_mod_post": False, + "score": 23, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": "gnome-user", + "author_flair_richtext": [{"e": "text", "t": "GNOMie"}], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594180508.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "github.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": "confidence", + "banned_at_utc": None, + "url_overridden_by_dest": "https://github.com/mpv-player/mpv/commit/cdaa496314f90412963f2b3211e18df72910066d#commitcomment-40434556", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "7dbe0c80-f9df-11e8-b35e-0e2ae22a2534", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": "GNOMie", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qjhn", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#692c52", + "id": "hn1s3r", + "is_robot_indexable": True, + "report_reasons": None, + "author": "idiot10000000", + "discussion_type": None, + "num_comments": 53, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/gnome/comments/hn1s3r/mpv_is_not_anymore_supporting_gnome_and_the_owner/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://github.com/mpv-player/mpv/commit/cdaa496314f90412963f2b3211e18df72910066d#commitcomment-40434556", + "subreddit_subscribers": 41350, + "created_utc": 1594151708.0, + "num_crossposts": 1, + "media": None, + "is_video": False, + } + ], + "created": 1594265700.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "github.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://github.com/mpv-player/mpv/commit/cdaa496314f90412963f2b3211e18df72910066d#commitcomment-40434556", + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnnt0v", + "is_robot_indexable": True, + "report_reasons": None, + "author": "RetartedTortoise", + "discussion_type": None, + "num_comments": 0, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_hn1s3r", + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnnt0v/mpv_is_not_anymore_supporting_gnome_and_the_owner/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://github.com/mpv-player/mpv/commit/cdaa496314f90412963f2b3211e18df72910066d#commitcomment-40434556", + "subreddit_subscribers": 543995, + "created_utc": 1594236900.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_21omsw7y", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Google and Canonical bring Linux apps support to Flutter - 9to5Google", + "link_flair_richtext": [{"e": "text", "t": "Development"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnj42j", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.59, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 3, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Development", + "can_mod_post": False, + "score": 3, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594251002.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "9to5google.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://9to5google.com/2020/07/08/google-canonical-partnership-linux-flutter-apps/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "3cb511e2-7914-11ea-bb33-0ee30ee9d22b", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#f0db8a", + "id": "hnj42j", + "is_robot_indexable": True, + "report_reasons": None, + "author": "satvikpendem", + "discussion_type": None, + "num_comments": 1, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnj42j/google_and_canonical_bring_linux_apps_support_to/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://9to5google.com/2020/07/08/google-canonical-partnership-linux-flutter-apps/", + "subreddit_subscribers": 543995, + "created_utc": 1594222202.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": " As far as I understand it, the current options on the Intel Iris/NVIDIA side are:\n\n* Intel or NVIDIA cards only\n\n* Optimus for switching between Intel and Intel+NVIDIA (requires reboot)\n\n* Bumblebee for on-the-fly switching with a performance hit\n\n* nvidia-xrun, which does everything bumblebee can do but requires a second X server\n\n* Prime Rener Offload, a proprietary NVIDIA thing, for switching between Intel and Intel+NVIDIA, which I don't completely understand\n\nDo I have this right? And how do things look on the Amd Vega/Radeon configuration?", + "author_fullname": "t2_tcnt4", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "[Discussion] What's the current status on laptop switchable graphics?", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": True, + "name": "t3_hnmiik", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.67, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 1, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 1, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594261813.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>As far as I understand it, the current options on the Intel Iris/NVIDIA side are:</p>\n\n<ul>\n<li><p>Intel or NVIDIA cards only</p></li>\n<li><p>Optimus for switching between Intel and Intel+NVIDIA (requires reboot)</p></li>\n<li><p>Bumblebee for on-the-fly switching with a performance hit</p></li>\n<li><p>nvidia-xrun, which does everything bumblebee can do but requires a second X server</p></li>\n<li><p>Prime Rener Offload, a proprietary NVIDIA thing, for switching between Intel and Intel+NVIDIA, which I don&#39;t completely understand</p></li>\n</ul>\n\n<p>Do I have this right? And how do things look on the Amd Vega/Radeon configuration?</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnmiik", + "is_robot_indexable": True, + "report_reasons": None, + "author": "KoolDude214", + "discussion_type": None, + "num_comments": 4, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnmiik/discussion_whats_the_current_status_on_laptop/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.reddit.com/r/linux/comments/hnmiik/discussion_whats_the_current_status_on_laptop/", + "subreddit_subscribers": 543995, + "created_utc": 1594233013.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "Hello all!\n\nI've created this simple web app as a part of learning web development, to help people select a linux distro for themselves.\n\nIt's a really simple web app, as I've created it as part of learning web development.\n\nIt retrieves data from another API that I've defined and this very API's database is used to store all the releated information that only right now I can store.\n\nAnd this web app is used to get information from that API and display it in an organized way.\n\nHave a look and please let me know about your thoughts and suggestions:\n\nLink: [https://linux-distros-encyclopedia.herokuapp.com/](https://linux-distros-encyclopedia.herokuapp.com/)", + "author_fullname": "t2_4c9tcvx3", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Linux Distributions Encyclopedia Web App", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnlh54", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.5, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 0, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 0, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594258586.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>Hello all!</p>\n\n<p>I&#39;ve created this simple web app as a part of learning web development, to help people select a linux distro for themselves.</p>\n\n<p>It&#39;s a really simple web app, as I&#39;ve created it as part of learning web development.</p>\n\n<p>It retrieves data from another API that I&#39;ve defined and this very API&#39;s database is used to store all the releated information that only right now I can store.</p>\n\n<p>And this web app is used to get information from that API and display it in an organized way.</p>\n\n<p>Have a look and please let me know about your thoughts and suggestions:</p>\n\n<p>Link: <a href="https://linux-distros-encyclopedia.herokuapp.com/">https://linux-distros-encyclopedia.herokuapp.com/</a></p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnlh54", + "is_robot_indexable": True, + "report_reasons": None, + "author": "MisterKhJe", + "discussion_type": None, + "num_comments": 2, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnlh54/linux_distributions_encyclopedia_web_app/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.reddit.com/r/linux/comments/hnlh54/linux_distributions_encyclopedia_web_app/", + "subreddit_subscribers": 543995, + "created_utc": 1594229786.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "I would like to turn my old Asus tablet into an ultimate linux-based Ebook reader. It's currently running kali linux due to my netsec background and I can't say that it runs flawlessly. The tablet came with Windows 10 by default. Does anyone have the experience with what distro and pdf reader to use?\n\nIt has to be lightweight due to 1.3Ghz Atom processor and 1Gb of Ram.", + "author_fullname": "t2_y0rlp", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Linux based Ebook reader tablet", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnecim", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.56, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 2, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 2, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594231304.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>I would like to turn my old Asus tablet into an ultimate linux-based Ebook reader. It&#39;s currently running kali linux due to my netsec background and I can&#39;t say that it runs flawlessly. The tablet came with Windows 10 by default. Does anyone have the experience with what distro and pdf reader to use?</p>\n\n<p>It has to be lightweight due to 1.3Ghz Atom processor and 1Gb of Ram.</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnecim", + "is_robot_indexable": True, + "report_reasons": None, + "author": "Kikur", + "discussion_type": None, + "num_comments": 5, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnecim/linux_based_ebook_reader_tablet/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.reddit.com/r/linux/comments/hnecim/linux_based_ebook_reader_tablet/", + "subreddit_subscribers": 543995, + "created_utc": 1594202504.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_300vb", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Backing up my work-provided Windows laptop with Debian, ZFS and SquashFS", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hn2ro8", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.74, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 23, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 23, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594183686.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "thanassis.space", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://www.thanassis.space/backupCOVID.html", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hn2ro8", + "is_robot_indexable": True, + "report_reasons": None, + "author": "ttsiodras", + "discussion_type": None, + "num_comments": 5, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hn2ro8/backing_up_my_workprovided_windows_laptop_with/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.thanassis.space/backupCOVID.html", + "subreddit_subscribers": 543995, + "created_utc": 1594154886.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_2ccbdhht", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Debian influences everywhere", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": True, + "name": "t3_hnndj2", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.36, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 0, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 0, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [ + { + "approved_at_utc": None, + "subreddit": "ramen", + "selftext": "", + "author_fullname": "t2_1e5jztuf", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "My 1st Attempt for Tori Paitan", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/ramen", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": True, + "name": "t3_hnn89u", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 1.0, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 2, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Homemade", + "can_mod_post": False, + "score": 2, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594263979.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/ai9r2wu5mo951.jpg", + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "28b48e48-ce25-11e8-94f2-0e1ed223bf48", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qykd", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#ffd635", + "id": "hnn89u", + "is_robot_indexable": True, + "report_reasons": None, + "author": "cheesychicken80", + "discussion_type": None, + "num_comments": 1, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/ramen/comments/hnn89u/my_1st_attempt_for_tori_paitan/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/ai9r2wu5mo951.jpg", + "subreddit_subscribers": 257000, + "created_utc": 1594235179.0, + "num_crossposts": 1, + "media": None, + "is_video": False, + } + ], + "created": 1594264403.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/ai9r2wu5mo951.jpg", + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnndj2", + "is_robot_indexable": True, + "report_reasons": None, + "author": "dracardOner", + "discussion_type": None, + "num_comments": 0, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_hnn89u", + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnndj2/debian_influences_everywhere/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/ai9r2wu5mo951.jpg", + "subreddit_subscribers": 543995, + "created_utc": 1594235603.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "There is an open issue in Electron-Builder to add option to easily create flatpak repo. This results in many electron apps not officially/easily supporting flatpak, thus solving this would help flatpak adoption and make it easier for users to install their favourite apps. See the issue on github for more info [https://github.com/electron-userland/electron-builder/issues/512](https://github.com/electron-userland/electron-builder/issues/512)\n\nSince there are no technical obstacles that prevent completing this task, I made a small bounty on gitpay [https://gitpay.me/#/task/352](https://gitpay.me/#/task/352) to motivate developers, and if you care about this issue, consider chiming in too, spreading the word or even giving a try at implementing this :)", + "author_fullname": "t2_5hgjidqm", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Crowdsource Flatpak support in Electron-Builder", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hmytic", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.76, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 37, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 37, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594171301.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>There is an open issue in Electron-Builder to add option to easily create flatpak repo. This results in many electron apps not officially/easily supporting flatpak, thus solving this would help flatpak adoption and make it easier for users to install their favourite apps. See the issue on github for more info <a href="https://github.com/electron-userland/electron-builder/issues/512">https://github.com/electron-userland/electron-builder/issues/512</a></p>\n\n<p>Since there are no technical obstacles that prevent completing this task, I made a small bounty on gitpay <a href="https://gitpay.me/#/task/352">https://gitpay.me/#/task/352</a> to motivate developers, and if you care about this issue, consider chiming in too, spreading the word or even giving a try at implementing this :)</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hmytic", + "is_robot_indexable": True, + "report_reasons": None, + "author": "ignapk", + "discussion_type": None, + "num_comments": 23, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hmytic/crowdsource_flatpak_support_in_electronbuilder/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.reddit.com/r/linux/comments/hmytic/crowdsource_flatpak_support_in_electronbuilder/", + "subreddit_subscribers": 543995, + "created_utc": 1594142501.0, + "num_crossposts": 5, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "I was experiencing graphic issues and glitches in some games while using Linux Ubuntu 20.04 LTS with my Ryzen 3 3250u CPU and I wanted to share how I fixed this issue for anyone else with this same problem.\n\nFirst thing you should try is setting 'AMD_DEBUG=nodmacopyimage' as an environmental variable. This only partly fixed the issue for me as most of the in-game textures were still glitchy and messed up. However this method seemed to work for some other people https://gitlab.freedesktop.org/mesa/mesa/-/issues/2814\n\nThe second method I tried was downgrading from Ubuntu 20.04 to Ubuntu 19.10. This fixed my problem instantly and the glitchy in-game textures were no longer an issue.\n\n\nIm still new to Linux and not very tech savvy so I can't provide a detailed explanation of what causes this problem and why these methods seem to fix it however I'm pretty sure its something to do with the AMD graphics drivers. Hopefully this issue will be fixed in the next Ubuntu update\n\nHope this helped ;)", + "author_fullname": "t2_6qntnayu", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Linux Graphical Glitches on Ryzen CPUs", + "link_flair_richtext": [{"e": "text", "t": "Tips and Tricks"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hmxiyt", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.79, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 20, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Tips and Tricks", + "can_mod_post": False, + "score": 20, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594167246.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>I was experiencing graphic issues and glitches in some games while using Linux Ubuntu 20.04 LTS with my Ryzen 3 3250u CPU and I wanted to share how I fixed this issue for anyone else with this same problem.</p>\n\n<p>First thing you should try is setting &#39;AMD_DEBUG=nodmacopyimage&#39; as an environmental variable. This only partly fixed the issue for me as most of the in-game textures were still glitchy and messed up. However this method seemed to work for some other people <a href="https://gitlab.freedesktop.org/mesa/mesa/-/issues/2814">https://gitlab.freedesktop.org/mesa/mesa/-/issues/2814</a></p>\n\n<p>The second method I tried was downgrading from Ubuntu 20.04 to Ubuntu 19.10. This fixed my problem instantly and the glitchy in-game textures were no longer an issue.</p>\n\n<p>Im still new to Linux and not very tech savvy so I can&#39;t provide a detailed explanation of what causes this problem and why these methods seem to fix it however I&#39;m pretty sure its something to do with the AMD graphics drivers. Hopefully this issue will be fixed in the next Ubuntu update</p>\n\n<p>Hope this helped ;)</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "de62f716-76df-11ea-802c-0e7469f68f6b", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#00a6a5", + "id": "hmxiyt", + "is_robot_indexable": True, + "report_reasons": None, + "author": "Inolicious_", + "discussion_type": None, + "num_comments": 9, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hmxiyt/linux_graphical_glitches_on_ryzen_cpus/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.reddit.com/r/linux/comments/hmxiyt/linux_graphical_glitches_on_ryzen_cpus/", + "subreddit_subscribers": 543995, + "created_utc": 1594138446.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "Well, alright, at various points in my life I may have been more pleased, but Windows has been losing my support for years one small nitpick at a time. Just wanted to share the change for whoever cares.\n\n* I liked the look and massive size of the windows less and less \n* As a programmer using bash and zsh on cygwin became more and more annoying\n* Windows keeps randomly changing stuff that I never wanted, like my downloads folder becoming a date-sorted list instead of an actual folder (and switching it back when I changed it!)\n* Adding cortana and the like and making it difficult to disable\n* Windows update.\n* Almost every bit of software I have at this point is also on linux or through a browser!\n\nI switched to Manjaro-Gnome and never looked back.\n\n* It's sleeker/runs faster.\n* Uses less RAM\n* Uses rolling updates\n* I can finally just use a built-in terminal\n* Has an easier to understand file structure, despite its complexity.\n* Is surprisingly easy to use. The only difficult part really was finding the wifi driver, and that was actually because it was mislabeled by the manufacturer.\n* Gnome is definitely nicer to use than Windows 10.\n* Searching for files and programs works well! I really didn't need windows to fail to find a program I had installed and instead offer to search for it online... through Bing on Edge.\n\nI never knew how much bloat Windows had until I switched over. This is so damn nice. I don't know why I didn't consider Linux as a serious alternative until recently. Steam Proton has also come a long, long way, I haven't had issues with a game yet.\n\nAnyways, I just wanted to rant, and I'm probably going to install an Manjaro-xfce on a bunch of old laptops.", + "author_fullname": "t2_8zm4y", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Switched from Windows 10 to Manjaro, never been happier", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hmgujt", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.92, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 598, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 598, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594099445.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>Well, alright, at various points in my life I may have been more pleased, but Windows has been losing my support for years one small nitpick at a time. Just wanted to share the change for whoever cares.</p>\n\n<ul>\n<li>I liked the look and massive size of the windows less and less </li>\n<li>As a programmer using bash and zsh on cygwin became more and more annoying</li>\n<li>Windows keeps randomly changing stuff that I never wanted, like my downloads folder becoming a date-sorted list instead of an actual folder (and switching it back when I changed it!)</li>\n<li>Adding cortana and the like and making it difficult to disable</li>\n<li>Windows update.</li>\n<li>Almost every bit of software I have at this point is also on linux or through a browser!</li>\n</ul>\n\n<p>I switched to Manjaro-Gnome and never looked back.</p>\n\n<ul>\n<li>It&#39;s sleeker/runs faster.</li>\n<li>Uses less RAM</li>\n<li>Uses rolling updates</li>\n<li>I can finally just use a built-in terminal</li>\n<li>Has an easier to understand file structure, despite its complexity.</li>\n<li>Is surprisingly easy to use. The only difficult part really was finding the wifi driver, and that was actually because it was mislabeled by the manufacturer.</li>\n<li>Gnome is definitely nicer to use than Windows 10.</li>\n<li>Searching for files and programs works well! I really didn&#39;t need windows to fail to find a program I had installed and instead offer to search for it online... through Bing on Edge.</li>\n</ul>\n\n<p>I never knew how much bloat Windows had until I switched over. This is so damn nice. I don&#39;t know why I didn&#39;t consider Linux as a serious alternative until recently. Steam Proton has also come a long, long way, I haven&#39;t had issues with a game yet.</p>\n\n<p>Anyways, I just wanted to rant, and I&#39;m probably going to install an Manjaro-xfce on a bunch of old laptops.</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hmgujt", + "is_robot_indexable": True, + "report_reasons": None, + "author": "ForShotgun", + "discussion_type": None, + "num_comments": 213, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hmgujt/switched_from_windows_10_to_manjaro_never_been/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.reddit.com/r/linux/comments/hmgujt/switched_from_windows_10_to_manjaro_never_been/", + "subreddit_subscribers": 543995, + "created_utc": 1594070645.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + ], + "after": "t3_hmgujt", + "before": None, + }, +} diff --git a/src/newsreader/news/collection/tests/reddit/stream/tests.py b/src/newsreader/news/collection/tests/reddit/stream/tests.py new file mode 100644 index 0000000..19aff61 --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/stream/tests.py @@ -0,0 +1,144 @@ +from json.decoder import JSONDecodeError +from unittest.mock import patch +from uuid import uuid4 + +from django.test import TestCase + +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamException, + StreamForbiddenException, + StreamNotFoundException, + StreamParseException, + StreamTimeOutException, +) +from newsreader.news.collection.reddit import RedditStream +from newsreader.news.collection.tests.factories import SubredditFactory +from newsreader.news.collection.tests.reddit.stream.mocks import simple_mock + + +class RedditStreamTestCase(TestCase): + def setUp(self): + self.maxDiff = None + + self.patched_fetch = patch("newsreader.news.collection.reddit.fetch") + self.mocked_fetch = self.patched_fetch.start() + + def tearDown(self): + patch.stopall() + + def test_simple_stream(self): + self.mocked_fetch.return_value.json.return_value = simple_mock + + access_token = str(uuid4()) + user = UserFactory(reddit_access_token=access_token) + + subreddit = SubredditFactory(user=user) + stream = RedditStream(subreddit) + + data, stream = stream.read() + + self.assertEquals(data, simple_mock) + self.assertEquals(stream, stream) + self.mocked_fetch.assert_called_once_with( + subreddit.url, headers={"Authorization": f"bearer {access_token}"} + ) + + def test_stream_raises_exception(self): + self.mocked_fetch.side_effect = StreamException + + access_token = str(uuid4()) + user = UserFactory(reddit_access_token=access_token) + + subreddit = SubredditFactory(user=user) + stream = RedditStream(subreddit) + + with self.assertRaises(StreamException): + stream.read() + + self.mocked_fetch.assert_called_once_with( + subreddit.url, headers={"Authorization": f"bearer {access_token}"} + ) + + def test_stream_raises_denied_exception(self): + self.mocked_fetch.side_effect = StreamDeniedException + + access_token = str(uuid4()) + user = UserFactory(reddit_access_token=access_token) + + subreddit = SubredditFactory(user=user) + stream = RedditStream(subreddit) + + with self.assertRaises(StreamDeniedException): + stream.read() + + self.mocked_fetch.assert_called_once_with( + subreddit.url, headers={"Authorization": f"bearer {access_token}"} + ) + + def test_stream_raises_not_found_exception(self): + self.mocked_fetch.side_effect = StreamNotFoundException + + access_token = str(uuid4()) + user = UserFactory(reddit_access_token=access_token) + + subreddit = SubredditFactory(user=user) + stream = RedditStream(subreddit) + + with self.assertRaises(StreamNotFoundException): + stream.read() + + self.mocked_fetch.assert_called_once_with( + subreddit.url, headers={"Authorization": f"bearer {access_token}"} + ) + + def test_stream_raises_time_out_exception(self): + self.mocked_fetch.side_effect = StreamTimeOutException + + access_token = str(uuid4()) + user = UserFactory(reddit_access_token=access_token) + + subreddit = SubredditFactory(user=user) + stream = RedditStream(subreddit) + + with self.assertRaises(StreamTimeOutException): + stream.read() + + self.mocked_fetch.assert_called_once_with( + subreddit.url, headers={"Authorization": f"bearer {access_token}"} + ) + + def test_stream_raises_forbidden_exception(self): + self.mocked_fetch.side_effect = StreamForbiddenException + + access_token = str(uuid4()) + user = UserFactory(reddit_access_token=access_token) + + subreddit = SubredditFactory(user=user) + stream = RedditStream(subreddit) + + with self.assertRaises(StreamForbiddenException): + stream.read() + + self.mocked_fetch.assert_called_once_with( + subreddit.url, headers={"Authorization": f"bearer {access_token}"} + ) + + def test_stream_raises_parse_exception(self): + self.mocked_fetch.return_value.json.side_effect = JSONDecodeError( + "No json found", "{}", 5 + ) + + access_token = str(uuid4()) + user = UserFactory(reddit_access_token=access_token) + + subreddit = SubredditFactory(user=user) + stream = RedditStream(subreddit) + + with self.assertRaises(StreamParseException): + stream.read() + + self.mocked_fetch.assert_called_once_with( + subreddit.url, headers={"Authorization": f"bearer {access_token}"} + ) diff --git a/src/newsreader/news/collection/tests/reddit/test_scheduler.py b/src/newsreader/news/collection/tests/reddit/test_scheduler.py new file mode 100644 index 0000000..cd062b6 --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/test_scheduler.py @@ -0,0 +1,142 @@ +from datetime import timedelta + +from django.test import TestCase +from django.utils import timezone + +from freezegun import freeze_time + +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.reddit import RedditScheduler +from newsreader.news.collection.tests.factories import CollectionRuleFactory + + +@freeze_time("2019-10-30 12:30:00") +class RedditSchedulerTestCase(TestCase): + def test_simple(self): + user_1 = UserFactory( + reddit_access_token="1231414", reddit_refresh_token="5235262" + ) + user_2 = UserFactory( + reddit_access_token="3414777", reddit_refresh_token="3423425" + ) + + user_1_rules = [ + CollectionRuleFactory( + user=user_1, + type=RuleTypeChoices.subreddit, + last_suceeded=timezone.now() - timedelta(days=4), + enabled=True, + ), + CollectionRuleFactory( + user=user_1, + type=RuleTypeChoices.subreddit, + last_suceeded=timezone.now() - timedelta(days=3), + enabled=True, + ), + CollectionRuleFactory( + user=user_1, + type=RuleTypeChoices.subreddit, + last_suceeded=timezone.now() - timedelta(days=2), + enabled=True, + ), + ] + + user_2_rules = [ + CollectionRuleFactory( + user=user_2, + type=RuleTypeChoices.subreddit, + last_suceeded=timezone.now() - timedelta(days=4), + enabled=True, + ), + CollectionRuleFactory( + user=user_2, + type=RuleTypeChoices.subreddit, + last_suceeded=timezone.now() - timedelta(days=3), + enabled=True, + ), + CollectionRuleFactory( + user=user_2, + type=RuleTypeChoices.subreddit, + last_suceeded=timezone.now() - timedelta(days=2), + enabled=True, + ), + ] + + scheduler = RedditScheduler() + scheduled_subreddits = scheduler.get_scheduled_rules() + + user_1_batch = [subreddit.pk for subreddit in scheduled_subreddits[0]] + + self.assertIn(user_1_rules[0].pk, user_1_batch) + self.assertIn(user_1_rules[1].pk, user_1_batch) + self.assertIn(user_1_rules[2].pk, user_1_batch) + + user_2_batch = [subreddit.pk for subreddit in scheduled_subreddits[1]] + + self.assertIn(user_2_rules[0].pk, user_2_batch) + self.assertIn(user_2_rules[1].pk, user_2_batch) + self.assertIn(user_2_rules[2].pk, user_2_batch) + + def test_max_amount(self): + users = UserFactory.create_batch( + reddit_access_token="1231414", reddit_refresh_token="5235262", size=5 + ) + + nested_rules = [ + CollectionRuleFactory.create_batch( + name=f"rule-{index}", + type=RuleTypeChoices.subreddit, + last_suceeded=timezone.now() - timedelta(seconds=index), + enabled=True, + user=user, + size=15, + ) + for index, user in enumerate(users) + ] + + rules = [rule for rule_list in nested_rules for rule in rule_list] + + scheduler = RedditScheduler() + scheduled_subreddits = [ + subreddit.pk + for batch in scheduler.get_scheduled_rules() + for subreddit in batch + ] + + for rule in rules[16:76]: + with self.subTest(rule=rule): + self.assertIn(rule.pk, scheduled_subreddits) + + for rule in rules[0:15]: + with self.subTest(rule=rule): + self.assertNotIn(rule.pk, scheduled_subreddits) + + def test_max_user_amount(self): + user = UserFactory( + reddit_access_token="1231414", reddit_refresh_token="5235262" + ) + + rules = [ + CollectionRuleFactory( + name=f"rule-{index}", + type=RuleTypeChoices.subreddit, + last_suceeded=timezone.now() - timedelta(seconds=index), + enabled=True, + user=user, + ) + for index in range(1, 17) + ] + + scheduler = RedditScheduler() + scheduled_subreddits = [ + subreddit.pk + for batch in scheduler.get_scheduled_rules() + for subreddit in batch + ] + + for rule in rules[1:16]: + with self.subTest(rule=rule): + self.assertIn(rule.pk, scheduled_subreddits) + + self.assertNotIn(rules[0].pk, scheduled_subreddits) diff --git a/src/newsreader/news/collection/tests/utils/tests.py b/src/newsreader/news/collection/tests/utils/tests.py index 95c5dd2..10013c3 100644 --- a/src/newsreader/news/collection/tests/utils/tests.py +++ b/src/newsreader/news/collection/tests/utils/tests.py @@ -6,97 +6,118 @@ from requests.exceptions import ConnectionError as RequestConnectionError from requests.exceptions import HTTPError, RequestException, SSLError, TooManyRedirects from newsreader.news.collection.exceptions import ( - StreamConnectionError, + StreamConnectionException, StreamDeniedException, StreamException, StreamForbiddenException, StreamNotFoundException, StreamTimeOutException, + StreamTooManyException, ) -from newsreader.news.collection.utils import fetch +from newsreader.news.collection.utils import fetch, post -class FetchTestCase(TestCase): - def setUp(self): - self.patched_get = patch("newsreader.news.collection.utils.requests.get") - self.mocked_get = self.patched_get.start() - +class HelperFunctionTestCase: def test_simple(self): - self.mocked_get.return_value = MagicMock(status_code=200, content="content") + self.mocked_method.return_value = MagicMock(status_code=200, content="content") url = "https://www.bbc.co.uk/news" - response = fetch(url) + response = self.method(url) self.assertEquals(response.content, "content") def test_raises_not_found(self): - self.mocked_get.return_value = MagicMock(status_code=404) + self.mocked_method.return_value = MagicMock(status_code=404) url = "https://www.bbc.co.uk/news" with self.assertRaises(StreamNotFoundException): - fetch(url) + self.method(url) def test_raises_denied(self): - self.mocked_get.return_value = MagicMock(status_code=401) + self.mocked_method.return_value = MagicMock(status_code=401) url = "https://www.bbc.co.uk/news" with self.assertRaises(StreamDeniedException): - fetch(url) + self.method(url) def test_raises_forbidden(self): - self.mocked_get.return_value = MagicMock(status_code=403) + self.mocked_method.return_value = MagicMock(status_code=403) url = "https://www.bbc.co.uk/news" with self.assertRaises(StreamForbiddenException): - fetch(url) + self.method(url) def test_raises_timed_out(self): - self.mocked_get.return_value = MagicMock(status_code=408) + self.mocked_method.return_value = MagicMock(status_code=408) url = "https://www.bbc.co.uk/news" with self.assertRaises(StreamTimeOutException): - fetch(url) + self.method(url) def test_raises_stream_error_on_ssl_error(self): - self.mocked_get.side_effect = SSLError + self.mocked_method.side_effect = SSLError url = "https://www.bbc.co.uk/news" with self.assertRaises(StreamException): - fetch(url) + self.method(url) def test_raises_stream_error_on_connection_error(self): - self.mocked_get.side_effect = RequestConnectionError + self.mocked_method.side_effect = RequestConnectionError url = "https://www.bbc.co.uk/news" - with self.assertRaises(StreamConnectionError): - fetch(url) + with self.assertRaises(StreamConnectionException): + self.method(url) def test_raises_stream_error_on_http_error(self): - self.mocked_get.side_effect = HTTPError + self.mocked_method.side_effect = HTTPError url = "https://www.bbc.co.uk/news" with self.assertRaises(StreamException): - fetch(url) + self.method(url) def test_raises_stream_error_on_request_exception(self): - self.mocked_get.side_effect = RequestException + self.mocked_method.side_effect = RequestException url = "https://www.bbc.co.uk/news" with self.assertRaises(StreamException): - fetch(url) + self.method(url) def test_raises_stream_error_on_too_many_redirects(self): - self.mocked_get.side_effect = TooManyRedirects + self.mocked_method.side_effect = TooManyRedirects url = "https://www.bbc.co.uk/news" with self.assertRaises(StreamException): - fetch(url) + self.method(url) + + def test_raises_stream_error_on_too_many_requests(self): + self.mocked_method.return_value = MagicMock(status_code=429) + + url = "https://www.bbc.co.uk/news" + + with self.assertRaises(StreamTooManyException): + self.method(url) + + +class FetchTestCase(HelperFunctionTestCase, TestCase): + def setUp(self): + self.patch = patch("newsreader.news.collection.utils.requests.get") + self.mocked_method = self.patch.start() + + self.method = fetch + + +class PostTestCase(HelperFunctionTestCase, TestCase): + def setUp(self): + self.patch = patch("newsreader.news.collection.utils.requests.post") + self.mocked_method = self.patch.start() + + self.method = post diff --git a/src/newsreader/news/collection/tests/views/__init__.py b/src/newsreader/news/collection/tests/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/views/base.py b/src/newsreader/news/collection/tests/views/base.py new file mode 100644 index 0000000..d7de171 --- /dev/null +++ b/src/newsreader/news/collection/tests/views/base.py @@ -0,0 +1,69 @@ +from django.urls import reverse + +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.core.tests.factories import CategoryFactory + + +class CollectionRuleViewTestCase: + def setUp(self): + self.user = UserFactory(password="test") + self.client.force_login(self.user) + + self.category = CategoryFactory(user=self.user) + self.form_data = {"name": "", "category": "", "url": "", "timezone": ""} + + def test_simple(self): + response = self.client.get(self.url) + + self.assertEquals(response.status_code, 200) + + def test_no_category(self): + self.form_data.update(category="") + + response = self.client.post(self.url, self.form_data) + self.assertEquals(response.status_code, 302) + + rule = CollectionRule.objects.get() + + self.assertEquals(rule.category, None) + + def test_categories_only_from_user(self): + other_user = UserFactory() + other_categories = CategoryFactory.create_batch(size=4, user=other_user) + + response = self.client.get(self.url) + + for category in other_categories: + self.assertNotContains(response, category.name) + + def test_category_of_other_user(self): + other_user = UserFactory() + other_rule = CollectionRuleFactory(name="other rule", user=other_user) + + self.form_data.update( + name="new name", + category=other_rule.category, + url=other_rule.url, + timezone=other_rule.timezone, + ) + + other_url = reverse("news:collection:rule-update", args=[other_rule.pk]) + response = self.client.post(other_url, self.form_data) + + self.assertEquals(response.status_code, 404) + + other_rule.refresh_from_db() + + self.assertEquals(other_rule.name, "other rule") + + def test_with_other_user_rules(self): + other_user = UserFactory() + other_categories = CategoryFactory.create_batch(size=4, user=other_user) + + self.form_data.update(category=other_categories[2].pk) + + response = self.client.post(self.url, self.form_data) + + self.assertContains(response, "not one of the available choices") diff --git a/src/newsreader/news/collection/tests/views/test_bulk_views.py b/src/newsreader/news/collection/tests/views/test_bulk_views.py index 39817c2..5112feb 100644 --- a/src/newsreader/news/collection/tests/views/test_bulk_views.py +++ b/src/newsreader/news/collection/tests/views/test_bulk_views.py @@ -4,7 +4,7 @@ from django.utils.translation import gettext_lazy as _ from newsreader.accounts.tests.factories import UserFactory from newsreader.news.collection.models import CollectionRule -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory class CollectionRuleBulkViewTestCase: @@ -21,9 +21,7 @@ class CollectionRuleBulkEnableViewTestCase(CollectionRuleBulkViewTestCase, TestC self.url = reverse("news:collection:rules-enable") - self.rules = CollectionRuleFactory.create_batch( - size=5, user=self.user, enabled=False - ) + self.rules = FeedFactory.create_batch(size=5, user=self.user, enabled=False) def test_simple(self): response = self.client.post( @@ -55,9 +53,7 @@ class CollectionRuleBulkEnableViewTestCase(CollectionRuleBulkViewTestCase, TestC def test_rule_from_other_user(self): other_user = UserFactory() - other_rules = CollectionRuleFactory.create_batch( - size=5, user=other_user, enabled=False - ) + other_rules = FeedFactory.create_batch(size=5, user=other_user, enabled=False) response = self.client.post( self.url, @@ -100,9 +96,7 @@ class CollectionRuleBulkDisableViewTestCase(CollectionRuleBulkViewTestCase, Test self.url = reverse("news:collection:rules-disable") - self.rules = CollectionRuleFactory.create_batch( - size=5, user=self.user, enabled=True - ) + self.rules = FeedFactory.create_batch(size=5, user=self.user, enabled=True) def test_simple(self): response = self.client.post( @@ -134,9 +128,7 @@ class CollectionRuleBulkDisableViewTestCase(CollectionRuleBulkViewTestCase, Test def test_rule_from_other_user(self): other_user = UserFactory() - other_rules = CollectionRuleFactory.create_batch( - size=5, user=other_user, enabled=True - ) + other_rules = FeedFactory.create_batch(size=5, user=other_user, enabled=True) response = self.client.post( self.url, @@ -179,7 +171,7 @@ class CollectionRuleBulkDeleteViewTestCase(CollectionRuleBulkViewTestCase, TestC self.url = reverse("news:collection:rules-delete") - self.rules = CollectionRuleFactory.create_batch(size=5, user=self.user) + self.rules = FeedFactory.create_batch(size=5, user=self.user) def test_simple(self): response = self.client.post( @@ -207,9 +199,7 @@ class CollectionRuleBulkDeleteViewTestCase(CollectionRuleBulkViewTestCase, TestC def test_rule_from_other_user(self): other_user = UserFactory() - other_rules = CollectionRuleFactory.create_batch( - size=5, user=other_user, enabled=True - ) + other_rules = FeedFactory.create_batch(size=5, user=other_user, enabled=True) response = self.client.post( self.url, diff --git a/src/newsreader/news/collection/tests/views/test_crud.py b/src/newsreader/news/collection/tests/views/test_crud.py index a581f0c..61f6835 100644 --- a/src/newsreader/news/collection/tests/views/test_crud.py +++ b/src/newsreader/news/collection/tests/views/test_crud.py @@ -3,80 +3,18 @@ from django.urls import reverse import pytz -from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.models import CollectionRule -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory +from newsreader.news.collection.tests.views.base import CollectionRuleViewTestCase from newsreader.news.core.tests.factories import CategoryFactory -class CollectionRuleViewTestCase: - def setUp(self): - self.user = UserFactory(password="test") - self.client.force_login(self.user) - - self.category = CategoryFactory(user=self.user) - self.form_data = {"name": "", "category": "", "url": "", "timezone": ""} - - def test_simple(self): - response = self.client.get(self.url) - - self.assertEquals(response.status_code, 200) - - def test_no_category(self): - self.form_data.update(category="") - - response = self.client.post(self.url, self.form_data) - self.assertEquals(response.status_code, 302) - - rule = CollectionRule.objects.get() - - self.assertEquals(rule.category, None) - - def test_categories_only_from_user(self): - other_user = UserFactory() - other_categories = CategoryFactory.create_batch(size=4, user=other_user) - - response = self.client.get(self.url) - - for category in other_categories: - self.assertNotContains(response, category.name) - - def test_category_of_other_user(self): - other_user = UserFactory() - other_rule = CollectionRuleFactory(name="other rule", user=other_user) - - self.form_data.update( - name="new name", - category=other_rule.category, - url=other_rule.url, - timezone=other_rule.timezone, - ) - - other_url = reverse("rule-update", args=[other_rule.pk]) - response = self.client.post(other_url, self.form_data) - - self.assertEquals(response.status_code, 404) - - other_rule.refresh_from_db() - - self.assertEquals(other_rule.name, "other rule") - - def test_with_other_user_rules(self): - other_user = UserFactory() - other_categories = CategoryFactory.create_batch(size=4, user=other_user) - - self.form_data.update(category=other_categories[2].pk) - - response = self.client.post(self.url, self.form_data) - - self.assertContains(response, "not one of the available choices") - - class CollectionRuleCreateViewTestCase(CollectionRuleViewTestCase, TestCase): def setUp(self): super().setUp() - self.url = reverse("rule-create") + self.url = reverse("news:collection:rule-create") self.form_data.update( name="new rule", @@ -92,6 +30,7 @@ class CollectionRuleCreateViewTestCase(CollectionRuleViewTestCase, TestCase): rule = CollectionRule.objects.get(name="new rule") + self.assertEquals(rule.type, RuleTypeChoices.feed) self.assertEquals(rule.url, "https://www.rss.com/rss") self.assertEquals(rule.timezone, str(pytz.utc)) self.assertEquals(rule.favicon, None) @@ -103,10 +42,10 @@ class CollectionRuleUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): def setUp(self): super().setUp() - self.rule = CollectionRuleFactory( + self.rule = FeedFactory( name="collection rule", user=self.user, category=self.category ) - self.url = reverse("rule-update", args=[self.rule.pk]) + self.url = reverse("news:collection:rule-update", kwargs={"pk": self.rule.pk}) self.form_data.update( name=self.rule.name, @@ -146,3 +85,17 @@ class CollectionRuleUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): self.rule.refresh_from_db() self.assertEquals(self.rule.category, None) + + def test_rules_only(self): + rule = FeedFactory( + name="Python", + url="https://reddit.com/r/python", + user=self.user, + category=self.category, + type=RuleTypeChoices.subreddit, + ) + url = reverse("news:collection:rule-update", kwargs={"pk": rule.pk}) + + response = self.client.get(url) + + self.assertEquals(response.status_code, 404) diff --git a/src/newsreader/news/collection/tests/views/test_import_view.py b/src/newsreader/news/collection/tests/views/test_import_view.py index 776e4c6..f4188e7 100644 --- a/src/newsreader/news/collection/tests/views/test_import_view.py +++ b/src/newsreader/news/collection/tests/views/test_import_view.py @@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _ from newsreader.accounts.tests.factories import UserFactory from newsreader.news.collection.models import CollectionRule -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory class OPMLImportTestCase(TestCase): @@ -16,7 +16,7 @@ class OPMLImportTestCase(TestCase): self.client.force_login(self.user) self.form_data = {"file": "", "skip_existing": False} - self.url = reverse("import") + self.url = reverse("news:collection:import") def _get_file_path(self, name): file_dir = os.path.join(settings.DJANGO_PROJECT_DIR, "utils", "tests", "files") @@ -30,22 +30,16 @@ class OPMLImportTestCase(TestCase): response = self.client.post(self.url, self.form_data) - self.assertRedirects(response, reverse("rules")) + self.assertRedirects(response, reverse("news:collection:rules")) rules = CollectionRule.objects.all() self.assertEquals(len(rules), 4) def test_existing_rules(self): - CollectionRuleFactory( - url="http://www.engadget.com/rss-full.xml", user=self.user - ) - CollectionRuleFactory(url="https://news.ycombinator.com/rss", user=self.user) - CollectionRuleFactory( - url="http://feeds.feedburner.com/Techcrunch", user=self.user - ) - CollectionRuleFactory( - url="http://feeds.feedburner.com/tweakers/nieuws", user=self.user - ) + FeedFactory(url="http://www.engadget.com/rss-full.xml", user=self.user) + FeedFactory(url="https://news.ycombinator.com/rss", user=self.user) + FeedFactory(url="http://feeds.feedburner.com/Techcrunch", user=self.user) + FeedFactory(url="http://feeds.feedburner.com/tweakers/nieuws", user=self.user) file_path = self._get_file_path("feeds.opml") @@ -54,22 +48,16 @@ class OPMLImportTestCase(TestCase): response = self.client.post(self.url, self.form_data) - self.assertRedirects(response, reverse("rules")) + self.assertRedirects(response, reverse("news:collection:rules")) rules = CollectionRule.objects.all() self.assertEquals(len(rules), 8) def test_skip_existing_rules(self): - CollectionRuleFactory( - url="http://www.engadget.com/rss-full.xml", user=self.user - ) - CollectionRuleFactory(url="https://news.ycombinator.com/rss", user=self.user) - CollectionRuleFactory( - url="http://feeds.feedburner.com/Techcrunch", user=self.user - ) - CollectionRuleFactory( - url="http://feeds.feedburner.com/tweakers/nieuws", user=self.user - ) + FeedFactory(url="http://www.engadget.com/rss-full.xml", user=self.user) + FeedFactory(url="https://news.ycombinator.com/rss", user=self.user) + FeedFactory(url="http://feeds.feedburner.com/Techcrunch", user=self.user) + FeedFactory(url="http://feeds.feedburner.com/tweakers/nieuws", user=self.user) file_path = self._get_file_path("feeds.opml") @@ -136,7 +124,7 @@ class OPMLImportTestCase(TestCase): response = self.client.post(self.url, self.form_data) - self.assertRedirects(response, reverse("rules")) + self.assertRedirects(response, reverse("news:collection:rules")) rules = CollectionRule.objects.all() self.assertEquals(len(rules), 2) diff --git a/src/newsreader/news/collection/tests/views/test_subreddit_views.py b/src/newsreader/news/collection/tests/views/test_subreddit_views.py new file mode 100644 index 0000000..a8de55e --- /dev/null +++ b/src/newsreader/news/collection/tests/views/test_subreddit_views.py @@ -0,0 +1,113 @@ +from django.test import TestCase +from django.urls import reverse + +import pytz + +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.reddit import REDDIT_URL +from newsreader.news.collection.tests.factories import SubredditFactory +from newsreader.news.collection.tests.views.base import CollectionRuleViewTestCase +from newsreader.news.core.tests.factories import CategoryFactory + + +class SubRedditCreateViewTestCase(CollectionRuleViewTestCase, TestCase): + def setUp(self): + super().setUp() + + self.form_data = { + "name": "new rule", + "url": "https://www.reddit.com/r/aww", + "category": str(self.category.pk), + } + + self.url = reverse("news:collection:subreddit-create") + + def test_creation(self): + response = self.client.post(self.url, self.form_data) + + self.assertEquals(response.status_code, 302) + + rule = CollectionRule.objects.get(name="new rule") + + self.assertEquals(rule.type, RuleTypeChoices.subreddit) + self.assertEquals(rule.url, "https://www.reddit.com/r/aww.json") + self.assertEquals(rule.timezone, str(pytz.utc)) + self.assertEquals(rule.favicon, None) + self.assertEquals(rule.category.pk, self.category.pk) + self.assertEquals(rule.user.pk, self.user.pk) + + +class SubRedditUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): + def setUp(self): + super().setUp() + + self.rule = SubredditFactory( + name="Python", + url=f"{REDDIT_URL}/r/python.json", + user=self.user, + category=self.category, + type=RuleTypeChoices.subreddit, + ) + self.url = reverse( + "news:collection:subreddit-update", kwargs={"pk": self.rule.pk} + ) + + self.form_data = { + "name": self.rule.name, + "url": self.rule.url, + "category": str(self.category.pk), + "timezone": pytz.utc, + } + + def test_name_change(self): + self.form_data.update(name="Python 2") + + response = self.client.post(self.url, self.form_data) + self.assertEquals(response.status_code, 302) + + self.rule.refresh_from_db() + + self.assertEquals(self.rule.name, "Python 2") + + def test_category_change(self): + new_category = CategoryFactory(user=self.user) + + self.form_data.update(category=new_category.pk) + + response = self.client.post(self.url, self.form_data) + self.assertEquals(response.status_code, 302) + + self.rule.refresh_from_db() + + self.assertEquals(self.rule.category.pk, new_category.pk) + + def test_subreddit_rules_only(self): + rule = SubredditFactory( + name="Fake subreddit", + url="https://leddit.com/r/python", + user=self.user, + category=self.category, + type=RuleTypeChoices.feed, + ) + url = reverse("news:collection:subreddit-update", kwargs={"pk": rule.pk}) + + response = self.client.get(url) + + self.assertEquals(response.status_code, 404) + + def test_url_change(self): + self.form_data.update(name="aww", url=f"{REDDIT_URL}/r/aww") + + response = self.client.post(self.url, self.form_data) + + self.assertEquals(response.status_code, 302) + + rule = CollectionRule.objects.get(name="aww") + + self.assertEquals(rule.type, RuleTypeChoices.subreddit) + self.assertEquals(rule.url, f"{REDDIT_URL}/r/aww.json") + self.assertEquals(rule.timezone, str(pytz.utc)) + self.assertEquals(rule.favicon, None) + self.assertEquals(rule.category.pk, self.category.pk) + self.assertEquals(rule.user.pk, self.user.pk) diff --git a/src/newsreader/news/collection/urls.py b/src/newsreader/news/collection/urls.py index 1ea17d6..5253210 100644 --- a/src/newsreader/news/collection/urls.py +++ b/src/newsreader/news/collection/urls.py @@ -15,6 +15,8 @@ from newsreader.news.collection.views import ( CollectionRuleListView, CollectionRuleUpdateView, OPMLImportView, + SubRedditCreateView, + SubRedditUpdateView, ) @@ -52,5 +54,15 @@ urlpatterns = [ login_required(CollectionRuleBulkDisableView.as_view()), name="rules-disable", ), + path( + "rules/subreddits/create/", + login_required(SubRedditCreateView.as_view()), + name="subreddit-create", + ), + path( + "rules/subreddits//", + login_required(SubRedditUpdateView.as_view()), + name="subreddit-update", + ), path("rules/import/", login_required(OPMLImportView.as_view()), name="import"), ] diff --git a/src/newsreader/news/collection/utils.py b/src/newsreader/news/collection/utils.py index 9a2e456..8ba6fec 100644 --- a/src/newsreader/news/collection/utils.py +++ b/src/newsreader/news/collection/utils.py @@ -1,5 +1,7 @@ from datetime import datetime +from django.db.models.fields import CharField, TextField +from django.template.defaultfilters import truncatechars from django.utils import timezone import pytz @@ -10,6 +12,9 @@ from requests.exceptions import RequestException from newsreader.news.collection.response_handler import ResponseHandler +DEFAULT_HEADERS = {"User-Agent": "linux:rss.fudiggity.nl:v0.2"} + + def build_publication_date(dt, tz): try: naive_datetime = datetime(*dt[:6]) @@ -20,12 +25,46 @@ def build_publication_date(dt, tz): return published_parsed.astimezone(pytz.utc) -def fetch(url): +def fetch(url, headers={}): + headers = {**DEFAULT_HEADERS, **headers} + with ResponseHandler() as response_handler: try: - response = requests.get(url) + response = requests.get(url, headers=headers) response_handler.handle_response(response) except RequestException as exception: - response_handler.handle_exception(exception) + response_handler.map_exception(exception) return response + + +def post(url, data=None, auth=None, headers={}): + headers = {**DEFAULT_HEADERS, **headers} + + with ResponseHandler() as response_handler: + try: + response = requests.post(url, data=data, auth=auth, headers=headers) + response_handler.handle_response(response) + except RequestException as exception: + response_handler.map_exception(exception) + + return response + + +def truncate_text(cls, field_name, value): + field = cls._meta.get_field(field_name) + max_length = field.max_length + field_cls = type(field) + + is_charfield = bool(issubclass(field_cls, CharField)) + is_textfield = bool(issubclass(field_cls, TextField)) + + if not value or not max_length: + return value + elif not is_charfield or is_textfield: + return value + + if len(value) > max_length: + return truncatechars(value, max_length) + + return value diff --git a/src/newsreader/news/collection/views/__init__.py b/src/newsreader/news/collection/views/__init__.py new file mode 100644 index 0000000..20769f3 --- /dev/null +++ b/src/newsreader/news/collection/views/__init__.py @@ -0,0 +1,13 @@ +from newsreader.news.collection.views.reddit import ( + SubRedditCreateView, + SubRedditUpdateView, +) +from newsreader.news.collection.views.rules import ( + CollectionRuleBulkDeleteView, + CollectionRuleBulkDisableView, + CollectionRuleBulkEnableView, + CollectionRuleCreateView, + CollectionRuleListView, + CollectionRuleUpdateView, + OPMLImportView, +) diff --git a/src/newsreader/news/collection/views/base.py b/src/newsreader/news/collection/views/base.py new file mode 100644 index 0000000..e7f7b63 --- /dev/null +++ b/src/newsreader/news/collection/views/base.py @@ -0,0 +1,36 @@ +from django.urls import reverse_lazy + +import pytz + +from newsreader.news.collection.forms import CollectionRuleForm +from newsreader.news.collection.models import CollectionRule +from newsreader.news.core.models import Category + + +class CollectionRuleViewMixin: + queryset = CollectionRule.objects.order_by("name") + + def get_queryset(self): + user = self.request.user + return self.queryset.filter(user=user) + + +class CollectionRuleDetailMixin: + success_url = reverse_lazy("news:collection:rules") + form_class = CollectionRuleForm + + def get_context_data(self, **kwargs): + context_data = super().get_context_data(**kwargs) + + categories = Category.objects.filter(user=self.request.user).order_by("name") + timezones = [timezone for timezone in pytz.all_timezones] + + context_data["categories"] = categories + context_data["timezones"] = timezones + + return context_data + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["user"] = self.request.user + return kwargs diff --git a/src/newsreader/news/collection/views/reddit.py b/src/newsreader/news/collection/views/reddit.py new file mode 100644 index 0000000..533513b --- /dev/null +++ b/src/newsreader/news/collection/views/reddit.py @@ -0,0 +1,26 @@ +from django.views.generic.edit import CreateView, UpdateView + +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.forms import SubRedditRuleForm +from newsreader.news.collection.views.base import ( + CollectionRuleDetailMixin, + CollectionRuleViewMixin, +) + + +class SubRedditCreateView( + CollectionRuleViewMixin, CollectionRuleDetailMixin, CreateView +): + form_class = SubRedditRuleForm + template_name = "news/collection/views/subreddit-create.html" + + +class SubRedditUpdateView( + CollectionRuleViewMixin, CollectionRuleDetailMixin, UpdateView +): + form_class = SubRedditRuleForm + template_name = "news/collection/views/subreddit-update.html" + + def get_queryset(self): + queryset = super().get_queryset() + return queryset.filter(type=RuleTypeChoices.subreddit) diff --git a/src/newsreader/news/collection/views.py b/src/newsreader/news/collection/views/rules.py similarity index 72% rename from src/newsreader/news/collection/views.py rename to src/newsreader/news/collection/views/rules.py index 6fb88df..e020b67 100644 --- a/src/newsreader/news/collection/views.py +++ b/src/newsreader/news/collection/views/rules.py @@ -1,51 +1,20 @@ from django.contrib import messages from django.shortcuts import redirect -from django.urls import reverse, reverse_lazy -from django.utils.translation import gettext_lazy as _ +from django.urls import reverse +from django.utils.translation import gettext as _ from django.views.generic.edit import CreateView, FormView, UpdateView from django.views.generic.list import ListView -import pytz - -from newsreader.news.collection.forms import ( - CollectionRuleBulkForm, - CollectionRuleForm, - OPMLImportForm, -) +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.forms import CollectionRuleBulkForm, OPMLImportForm from newsreader.news.collection.models import CollectionRule -from newsreader.news.core.models import Category +from newsreader.news.collection.views.base import ( + CollectionRuleDetailMixin, + CollectionRuleViewMixin, +) from newsreader.utils.opml import parse_opml -class CollectionRuleViewMixin: - queryset = CollectionRule.objects.order_by("name") - - def get_queryset(self): - user = self.request.user - return self.queryset.filter(user=user).order_by("name") - - -class CollectionRuleDetailMixin: - success_url = reverse_lazy("news:collection:rules") - form_class = CollectionRuleForm - - def get_context_data(self, **kwargs): - context_data = super().get_context_data(**kwargs) - - rules = Category.objects.filter(user=self.request.user).order_by("name") - timezones = [timezone for timezone in pytz.all_timezones] - - context_data["categories"] = rules - context_data["timezones"] = timezones - - return context_data - - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs["user"] = self.request.user - return kwargs - - class CollectionRuleListView(CollectionRuleViewMixin, ListView): paginate_by = 50 template_name = "news/collection/views/rules.html" @@ -58,6 +27,10 @@ class CollectionRuleUpdateView( template_name = "news/collection/views/rule-update.html" context_object_name = "rule" + def get_queryset(self): + queryset = super().get_queryset() + return queryset.filter(type=RuleTypeChoices.feed) + class CollectionRuleCreateView( CollectionRuleViewMixin, CollectionRuleDetailMixin, CreateView @@ -121,7 +94,6 @@ class CollectionRuleBulkDeleteView(CollectionRuleBulkView): class OPMLImportView(FormView): form_class = OPMLImportForm - success_url = reverse_lazy("news:collection:rules") template_name = "news/collection/views/import.html" def form_valid(self, form): @@ -145,3 +117,6 @@ class OPMLImportView(FormView): messages.success(self.request, message) return super().form_valid(form) + + def get_success_url(self): + return reverse("news:collection:rules") diff --git a/src/newsreader/news/core/migrations/0007_auto_20200706_2312.py b/src/newsreader/news/core/migrations/0007_auto_20200706_2312.py new file mode 100644 index 0000000..751faf9 --- /dev/null +++ b/src/newsreader/news/core/migrations/0007_auto_20200706_2312.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.7 on 2020-07-06 21:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("core", "0006_auto_20200524_1218")] + + operations = [ + migrations.AlterField( + model_name="post", + name="body", + field=models.TextField(blank=True, default=""), + preserve_default=False, + ) + ] diff --git a/src/newsreader/scss/components/section/_text-section.scss b/src/newsreader/scss/components/section/_text-section.scss index 88e3e72..9c5e8fc 100644 --- a/src/newsreader/scss/components/section/_text-section.scss +++ b/src/newsreader/scss/components/section/_text-section.scss @@ -8,4 +8,3 @@ background-color: $white; } - diff --git a/src/newsreader/scss/elements/button/_button.scss b/src/newsreader/scss/elements/button/_button.scss index 2e97d6b..50af49e 100644 --- a/src/newsreader/scss/elements/button/_button.scss +++ b/src/newsreader/scss/elements/button/_button.scss @@ -43,4 +43,13 @@ background-color: lighten($button-blue, 5%); } } + + &--reddit { + color: $white !important; + background-color: lighten($reddit-orange, 5%); + + &:hover { + background-color: $reddit-orange; + } + } } diff --git a/src/newsreader/scss/pages/settings/index.scss b/src/newsreader/scss/pages/settings/index.scss index 28837cd..c52f46b 100644 --- a/src/newsreader/scss/pages/settings/index.scss +++ b/src/newsreader/scss/pages/settings/index.scss @@ -1,11 +1,11 @@ #settings--page { - .settings-form__fieldset:last-child { - & span { - display: flex; - flex-direction: row; - - & >:first-child { - margin: 0 5px; + .form { + &__section { + &--last { + & .fieldset { + gap: 15px; + justify-content: flex-start; + } } } } diff --git a/src/newsreader/scss/partials/_colors.scss b/src/newsreader/scss/partials/_colors.scss index 08c7169..aee33c2 100644 --- a/src/newsreader/scss/partials/_colors.scss +++ b/src/newsreader/scss/partials/_colors.scss @@ -40,3 +40,5 @@ $white: rgba(255, 255, 255, 1); $black: rgba(0, 0, 0, 1); $blue: darken($azureish-white, +50%); $dark: rgba(0, 0, 0, 0.4); + +$reddit-orange: rgba(255, 69, 0, 1); diff --git a/src/newsreader/templates/admin/base_site.html b/src/newsreader/templates/admin/base_site.html new file mode 100644 index 0000000..c9d88b8 --- /dev/null +++ b/src/newsreader/templates/admin/base_site.html @@ -0,0 +1,6 @@ +{% extends "admin/base.html" %} +{% load static %} + +{% block extrahead %} + +{% endblock %} From 7cef924c3706495ed608b7a14a8214bb8e48db39 Mon Sep 17 00:00:00 2001 From: sonny Date: Sun, 12 Jul 2020 20:52:56 +0200 Subject: [PATCH 120/422] Fix reddit redirect url --- src/newsreader/conf/production.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/newsreader/conf/production.py b/src/newsreader/conf/production.py index 0dee323..852498e 100644 --- a/src/newsreader/conf/production.py +++ b/src/newsreader/conf/production.py @@ -48,7 +48,7 @@ TEMPLATES = [ # Reddit integration REDDIT_CLIENT_ID = os.environ["REDDIT_CLIENT_ID"] REDDIT_CLIENT_SECRET = os.environ["REDDIT_CLIENT_SECRET"] -REDDIT_REDIRECT_URL = "https://rss.fudiggity.nl/settings/reddit/callback/" +REDDIT_REDIRECT_URL = "https://rss.fudiggity.nl/accounts/settings/reddit/callback/" # Third party settings AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler" From 7d28dd854f15fea9d9b5ac1dd68a040605370ec7 Mon Sep 17 00:00:00 2001 From: sonny Date: Sun, 12 Jul 2020 20:56:47 +0200 Subject: [PATCH 121/422] Squashed commit of the following: commit 99fd94580f95dcbfb77b73e2de846f76a5709ef9 Author: Sonny Date: Sat Feb 15 21:45:16 2020 +0100 Use postgres password As of https://gitlab.com/gitlab-com/support-forum/issues/5199 --- src/newsreader/conf/production.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/newsreader/conf/production.py b/src/newsreader/conf/production.py index 0dee323..852498e 100644 --- a/src/newsreader/conf/production.py +++ b/src/newsreader/conf/production.py @@ -48,7 +48,7 @@ TEMPLATES = [ # Reddit integration REDDIT_CLIENT_ID = os.environ["REDDIT_CLIENT_ID"] REDDIT_CLIENT_SECRET = os.environ["REDDIT_CLIENT_SECRET"] -REDDIT_REDIRECT_URL = "https://rss.fudiggity.nl/settings/reddit/callback/" +REDDIT_REDIRECT_URL = "https://rss.fudiggity.nl/accounts/settings/reddit/callback/" # Third party settings AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler" From 5ce5c5cfe1be50a0121c28746c4ef431326d65a1 Mon Sep 17 00:00:00 2001 From: sonny Date: Sun, 12 Jul 2020 22:26:49 +0200 Subject: [PATCH 122/422] Set reddit callback url through env var --- src/newsreader/conf/production.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/newsreader/conf/production.py b/src/newsreader/conf/production.py index 852498e..5bc11a9 100644 --- a/src/newsreader/conf/production.py +++ b/src/newsreader/conf/production.py @@ -48,7 +48,7 @@ TEMPLATES = [ # Reddit integration REDDIT_CLIENT_ID = os.environ["REDDIT_CLIENT_ID"] REDDIT_CLIENT_SECRET = os.environ["REDDIT_CLIENT_SECRET"] -REDDIT_REDIRECT_URL = "https://rss.fudiggity.nl/accounts/settings/reddit/callback/" +REDDIT_REDIRECT_URL = os.environ["REDDIT_CALLBACK_URL"] # Third party settings AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler" From 24b1704da605002f1190d45fa2998df8074cf794 Mon Sep 17 00:00:00 2001 From: sonny Date: Sun, 12 Jul 2020 22:33:40 +0200 Subject: [PATCH 123/422] Update subreddit helptext --- src/newsreader/news/collection/forms.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/newsreader/news/collection/forms.py b/src/newsreader/news/collection/forms.py index 1d9b996..a8aac52 100644 --- a/src/newsreader/news/collection/forms.py +++ b/src/newsreader/news/collection/forms.py @@ -13,7 +13,8 @@ def get_reddit_help_text(): return mark_safe( "Only subreddits are supported. For example: " "https://www.reddit.com/r/aww" + " href='https://reddit.com/r/aww'>https://www.reddit.com/r/aww." + " Note that subreddit urls should NOT include 'www' because Reddit is picky." ) From ed8584603e3de40cbff2f6b2f4879b9c12c26fe7 Mon Sep 17 00:00:00 2001 From: sonny Date: Mon, 13 Jul 2020 23:14:43 +0200 Subject: [PATCH 124/422] Deduplicate reddit posts --- src/newsreader/news/collection/reddit.py | 18 +- .../collection/tests/reddit/builder/mocks.py | 333 ++++++++++++++++++ .../collection/tests/reddit/builder/tests.py | 60 +++- src/newsreader/news/core/tests/factories.py | 12 +- 4 files changed, 404 insertions(+), 19 deletions(-) diff --git a/src/newsreader/news/collection/reddit.py b/src/newsreader/news/collection/reddit.py index 2bb7bd9..1e2837b 100644 --- a/src/newsreader/news/collection/reddit.py +++ b/src/newsreader/news/collection/reddit.py @@ -111,6 +111,8 @@ class RedditBuilder(Builder): self.instances = self.build(posts, stream.rule) def build(self, posts, rule): + results = {} + for post in posts: if not "data" in post: continue @@ -120,6 +122,9 @@ class RedditBuilder(Builder): author = truncate_text(Post, "author", post["data"]["author"]) url_fragment = f"{post['data']['permalink']}" + if remote_identifier in results: + continue + uncleaned_body = post["data"]["selftext_html"] unescaped_body = unescape(uncleaned_body) if uncleaned_body else "" body = ( @@ -154,14 +159,15 @@ class RedditBuilder(Builder): if remote_identifier in self.existing_posts: existing_post = self.existing_posts[remote_identifier] - if created_date > existing_post.publication_date: - for key, value in data.items(): - setattr(existing_post, key, value) + for key, value in data.items(): + setattr(existing_post, key, value) - yield existing_post - continue + results[existing_post.remote_identifier] = existing_post + continue - yield Post(**data) + results[remote_identifier] = Post(**data) + + return results.values() def save(self): for post in self.instances: diff --git a/src/newsreader/news/collection/tests/reddit/builder/mocks.py b/src/newsreader/news/collection/tests/reddit/builder/mocks.py index 53ce372..fabc802 100644 --- a/src/newsreader/news/collection/tests/reddit/builder/mocks.py +++ b/src/newsreader/news/collection/tests/reddit/builder/mocks.py @@ -1376,3 +1376,336 @@ title_mock = { "before": None, }, } + +duplicate_mock = { + "kind": "Listing", + "data": { + "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", + "dist": 27, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "Welcome to r/linux rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.\n\nLet us know what's annoying you, whats making you happy, or something that you want to get out to r/linux but didn't make the cut into a full post of it's own.\n\nFor those looking for certifications please use this megathread to ask about how to get certified whether it's for the business world or for your own satisfaction. Be sure to check out r/linuxadmin for more discussion in the SysAdmin world!\n\n_Please keep questions in r/linuxquestions, r/linux4noobs, or the Wednesday automod thread._", + "author_fullname": "t2_6l4z3", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hm0qct", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.7, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 8, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 8, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594037482.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": "<!-- SC_OFF --><div class='md'><p>Welcome to <a href='/r/linux'>r/linux</a> rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.</p>\n\n<p>Let us know what&#39;s annoying you, whats making you happy, or something that you want to get out to <a href='/r/linux'>r/linux</a> but didn&#39;t make the cut into a full post of it&#39;s own.</p>\n\n<p>For those looking for certifications please use this megathread to ask about how to get certified whether it&#39;s for the business world or for your own satisfaction. Be sure to check out <a href='/r/linuxadmin'>r/linuxadmin</a> for more discussion in the SysAdmin world!</p>\n\n<p><em>Please keep questions in <a href='/r/linuxquestions'>r/linuxquestions</a>, <a href='/r/linux4noobs'>r/linux4noobs</a>, or the Wednesday automod thread.</em></p>\n</div><!-- SC_ON -->", + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": "moderator", + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hm0qct", + "is_robot_indexable": True, + "report_reasons": None, + "author": "AutoModerator", + "discussion_type": None, + "num_comments": 9, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", + "subreddit_subscribers": 544037, + "created_utc": 1594008682.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "Welcome to r/linux! If you're new to Linux or trying to get started this thread is for you. Get help here or as always, check out r/linuxquestions or r/linux4noobs\n\nThis megathread is for all your question needs. As we don't allow questions on r/linux outside of this megathread, please consider using r/linuxquestions or r/linux4noobs for the best solution to your problem.\n\nAsk your hardware requests here too or try r/linuxhardware!", + "author_fullname": "t2_6l4z3", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Weekly Questions and Hardware Thread - July 08, 2020", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hna75r", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.6, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 2, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 2, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594210138.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>Welcome to <a href="/r/linux">r/linux</a>! If you&#39;re new to Linux or trying to get started this thread is for you. Get help here or as always, check out <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a></p>\n\n<p>This megathread is for all your question needs. As we don&#39;t allow questions on <a href="/r/linux">r/linux</a> outside of this megathread, please consider using <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a> for the best solution to your problem.</p>\n\n<p>Ask your hardware requests here too or try <a href="/r/linuxhardware">r/linuxhardware</a>!</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": "new", + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": "moderator", + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hna75r", + "is_robot_indexable": True, + "report_reasons": None, + "author": "AutoModerator", + "discussion_type": None, + "num_comments": 2, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", + "subreddit_subscribers": 544037, + "created_utc": 1594181338.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "Welcome to r/linux rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.\n\nLet us know what's annoying you, whats making you happy, or something that you want to get out to r/linux but didn't make the cut into a full post of it's own.\n\nFor those looking for certifications please use this megathread to ask about how to get certified whether it's for the business world or for your own satisfaction. Be sure to check out r/linuxadmin for more discussion in the SysAdmin world!\n\n_Please keep questions in r/linuxquestions, r/linux4noobs, or the Wednesday automod thread._", + "author_fullname": "t2_6l4z3", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hm0qct", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.7, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 8, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 8, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594037482.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": "<!-- SC_OFF --><div class='md'><p>Welcome to <a href='/r/linux'>r/linux</a> rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.</p>\n\n<p>Let us know what&#39;s annoying you, whats making you happy, or something that you want to get out to <a href='/r/linux'>r/linux</a> but didn&#39;t make the cut into a full post of it&#39;s own.</p>\n\n<p>For those looking for certifications please use this megathread to ask about how to get certified whether it&#39;s for the business world or for your own satisfaction. Be sure to check out <a href='/r/linuxadmin'>r/linuxadmin</a> for more discussion in the SysAdmin world!</p>\n\n<p><em>Please keep questions in <a href='/r/linuxquestions'>r/linuxquestions</a>, <a href='/r/linux4noobs'>r/linux4noobs</a>, or the Wednesday automod thread.</em></p>\n</div><!-- SC_ON -->", + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": "moderator", + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hm0qct", + "is_robot_indexable": True, + "report_reasons": None, + "author": "AutoModerator", + "discussion_type": None, + "num_comments": 9, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", + "subreddit_subscribers": 544037, + "created_utc": 1594008682.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + ], + "after": "t3_hmytic", + "before": None, + }, +} diff --git a/src/newsreader/news/collection/tests/reddit/builder/tests.py b/src/newsreader/news/collection/tests/reddit/builder/tests.py index 3085199..eb8182a 100644 --- a/src/newsreader/news/collection/tests/reddit/builder/tests.py +++ b/src/newsreader/news/collection/tests/reddit/builder/tests.py @@ -7,16 +7,9 @@ import pytz from newsreader.news.collection.reddit import RedditBuilder from newsreader.news.collection.tests.factories import SubredditFactory -from newsreader.news.collection.tests.reddit.builder.mocks import ( - author_mock, - empty_mock, - simple_mock, - title_mock, - unknown_mock, - unsanitized_mock, -) +from newsreader.news.collection.tests.reddit.builder.mocks import * from newsreader.news.core.models import Post -from newsreader.news.core.tests.factories import PostFactory +from newsreader.news.core.tests.factories import RedditPostFactory class RedditBuilderTestCase(TestCase): @@ -92,10 +85,8 @@ class RedditBuilderTestCase(TestCase): def test_update_posts(self): subreddit = SubredditFactory() - existing_publication_date = pytz.utc.localize(datetime(2020, 7, 8, 14, 0, 0)) - existing_post = PostFactory( + existing_post = RedditPostFactory( remote_identifier="hngsj8", - publication_date=existing_publication_date, author="Old author", title="Old title", body="Old body", @@ -183,3 +174,48 @@ class RedditBuilderTestCase(TestCase): post.title, 'Board statement on the LibreOffice 7.0 RC "Personal EditionBoard statement on the LibreOffice 7.0 RC "Personal Edition" label" labelBoard statement on the LibreOffice 7.0 RC "PersBoard statement on t…', ) + + def test_duplicate_in_response(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((duplicate_mock, mock_stream)) as builder: + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertEquals(Post.objects.count(), 2) + self.assertCountEqual(("hm0qct", "hna75r"), posts.keys()) + + def test_duplicate_in_database(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + duplicate_post = RedditPostFactory( + remote_identifier="hm0qct", rule=subreddit, title="foo" + ) + + with builder((simple_mock, mock_stream)) as builder: + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertEquals(Post.objects.count(), 5) + self.assertCountEqual( + ("hm0qct", "hna75r", "hngs71", "hngsj8", "hnd7cy"), posts.keys() + ) + + duplicate_post.refresh_from_db() + + self.assertEquals( + duplicate_post.publication_date, + pytz.utc.localize(datetime(2020, 7, 6, 6, 11, 22)), + ) + self.assertEquals( + duplicate_post.title, + "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", + ) diff --git a/src/newsreader/news/core/tests/factories.py b/src/newsreader/news/core/tests/factories.py index 46eeeae..966e70b 100644 --- a/src/newsreader/news/core/tests/factories.py +++ b/src/newsreader/news/core/tests/factories.py @@ -1,7 +1,9 @@ import factory +import factory.fuzzy import pytz from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.reddit import REDDIT_URL from newsreader.news.core.models import Category, Post @@ -19,7 +21,7 @@ class PostFactory(factory.django.DjangoModelFactory): author = factory.Faker("name") publication_date = factory.Faker("date_time_this_year", tzinfo=pytz.utc) url = factory.Faker("url") - remote_identifier = factory.Faker("url") + remote_identifier = factory.Faker("uuid4") rule = factory.SubFactory( "newsreader.news.collection.tests.factories.CollectionRuleFactory" @@ -29,3 +31,11 @@ class PostFactory(factory.django.DjangoModelFactory): class Meta: model = Post + + +class RedditPostFactory(PostFactory): + remote_identifier = factory.Faker("uuid4") + url = factory.fuzzy.FuzzyText(length=10, prefix=f"{REDDIT_URL}/") + rule = factory.SubFactory( + "newsreader.news.collection.tests.factories.SubredditFactory" + ) From e8947d1182df83c68c3afa15da60ff72d66ec2d5 Mon Sep 17 00:00:00 2001 From: sonny Date: Mon, 13 Jul 2020 23:18:52 +0200 Subject: [PATCH 125/422] Squashed commit of the following: commit 99fd94580f95dcbfb77b73e2de846f76a5709ef9 Author: Sonny Date: Sat Feb 15 21:45:16 2020 +0100 Use postgres password As of https://gitlab.com/gitlab-com/support-forum/issues/5199 --- src/newsreader/conf/production.py | 2 +- src/newsreader/news/collection/forms.py | 3 +- src/newsreader/news/collection/reddit.py | 18 +- .../collection/tests/reddit/builder/mocks.py | 333 ++++++++++++++++++ .../collection/tests/reddit/builder/tests.py | 60 +++- src/newsreader/news/core/tests/factories.py | 12 +- 6 files changed, 407 insertions(+), 21 deletions(-) diff --git a/src/newsreader/conf/production.py b/src/newsreader/conf/production.py index 852498e..5bc11a9 100644 --- a/src/newsreader/conf/production.py +++ b/src/newsreader/conf/production.py @@ -48,7 +48,7 @@ TEMPLATES = [ # Reddit integration REDDIT_CLIENT_ID = os.environ["REDDIT_CLIENT_ID"] REDDIT_CLIENT_SECRET = os.environ["REDDIT_CLIENT_SECRET"] -REDDIT_REDIRECT_URL = "https://rss.fudiggity.nl/accounts/settings/reddit/callback/" +REDDIT_REDIRECT_URL = os.environ["REDDIT_CALLBACK_URL"] # Third party settings AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler" diff --git a/src/newsreader/news/collection/forms.py b/src/newsreader/news/collection/forms.py index 1d9b996..a8aac52 100644 --- a/src/newsreader/news/collection/forms.py +++ b/src/newsreader/news/collection/forms.py @@ -13,7 +13,8 @@ def get_reddit_help_text(): return mark_safe( "Only subreddits are supported. For example: " "https://www.reddit.com/r/aww" + " href='https://reddit.com/r/aww'>https://www.reddit.com/r/aww." + " Note that subreddit urls should NOT include 'www' because Reddit is picky." ) diff --git a/src/newsreader/news/collection/reddit.py b/src/newsreader/news/collection/reddit.py index 2bb7bd9..1e2837b 100644 --- a/src/newsreader/news/collection/reddit.py +++ b/src/newsreader/news/collection/reddit.py @@ -111,6 +111,8 @@ class RedditBuilder(Builder): self.instances = self.build(posts, stream.rule) def build(self, posts, rule): + results = {} + for post in posts: if not "data" in post: continue @@ -120,6 +122,9 @@ class RedditBuilder(Builder): author = truncate_text(Post, "author", post["data"]["author"]) url_fragment = f"{post['data']['permalink']}" + if remote_identifier in results: + continue + uncleaned_body = post["data"]["selftext_html"] unescaped_body = unescape(uncleaned_body) if uncleaned_body else "" body = ( @@ -154,14 +159,15 @@ class RedditBuilder(Builder): if remote_identifier in self.existing_posts: existing_post = self.existing_posts[remote_identifier] - if created_date > existing_post.publication_date: - for key, value in data.items(): - setattr(existing_post, key, value) + for key, value in data.items(): + setattr(existing_post, key, value) - yield existing_post - continue + results[existing_post.remote_identifier] = existing_post + continue - yield Post(**data) + results[remote_identifier] = Post(**data) + + return results.values() def save(self): for post in self.instances: diff --git a/src/newsreader/news/collection/tests/reddit/builder/mocks.py b/src/newsreader/news/collection/tests/reddit/builder/mocks.py index 53ce372..fabc802 100644 --- a/src/newsreader/news/collection/tests/reddit/builder/mocks.py +++ b/src/newsreader/news/collection/tests/reddit/builder/mocks.py @@ -1376,3 +1376,336 @@ title_mock = { "before": None, }, } + +duplicate_mock = { + "kind": "Listing", + "data": { + "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", + "dist": 27, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "Welcome to r/linux rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.\n\nLet us know what's annoying you, whats making you happy, or something that you want to get out to r/linux but didn't make the cut into a full post of it's own.\n\nFor those looking for certifications please use this megathread to ask about how to get certified whether it's for the business world or for your own satisfaction. Be sure to check out r/linuxadmin for more discussion in the SysAdmin world!\n\n_Please keep questions in r/linuxquestions, r/linux4noobs, or the Wednesday automod thread._", + "author_fullname": "t2_6l4z3", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hm0qct", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.7, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 8, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 8, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594037482.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": "<!-- SC_OFF --><div class='md'><p>Welcome to <a href='/r/linux'>r/linux</a> rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.</p>\n\n<p>Let us know what&#39;s annoying you, whats making you happy, or something that you want to get out to <a href='/r/linux'>r/linux</a> but didn&#39;t make the cut into a full post of it&#39;s own.</p>\n\n<p>For those looking for certifications please use this megathread to ask about how to get certified whether it&#39;s for the business world or for your own satisfaction. Be sure to check out <a href='/r/linuxadmin'>r/linuxadmin</a> for more discussion in the SysAdmin world!</p>\n\n<p><em>Please keep questions in <a href='/r/linuxquestions'>r/linuxquestions</a>, <a href='/r/linux4noobs'>r/linux4noobs</a>, or the Wednesday automod thread.</em></p>\n</div><!-- SC_ON -->", + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": "moderator", + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hm0qct", + "is_robot_indexable": True, + "report_reasons": None, + "author": "AutoModerator", + "discussion_type": None, + "num_comments": 9, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", + "subreddit_subscribers": 544037, + "created_utc": 1594008682.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "Welcome to r/linux! If you're new to Linux or trying to get started this thread is for you. Get help here or as always, check out r/linuxquestions or r/linux4noobs\n\nThis megathread is for all your question needs. As we don't allow questions on r/linux outside of this megathread, please consider using r/linuxquestions or r/linux4noobs for the best solution to your problem.\n\nAsk your hardware requests here too or try r/linuxhardware!", + "author_fullname": "t2_6l4z3", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Weekly Questions and Hardware Thread - July 08, 2020", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hna75r", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.6, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 2, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 2, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594210138.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>Welcome to <a href="/r/linux">r/linux</a>! If you&#39;re new to Linux or trying to get started this thread is for you. Get help here or as always, check out <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a></p>\n\n<p>This megathread is for all your question needs. As we don&#39;t allow questions on <a href="/r/linux">r/linux</a> outside of this megathread, please consider using <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a> for the best solution to your problem.</p>\n\n<p>Ask your hardware requests here too or try <a href="/r/linuxhardware">r/linuxhardware</a>!</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": "new", + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": "moderator", + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hna75r", + "is_robot_indexable": True, + "report_reasons": None, + "author": "AutoModerator", + "discussion_type": None, + "num_comments": 2, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", + "subreddit_subscribers": 544037, + "created_utc": 1594181338.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "Welcome to r/linux rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.\n\nLet us know what's annoying you, whats making you happy, or something that you want to get out to r/linux but didn't make the cut into a full post of it's own.\n\nFor those looking for certifications please use this megathread to ask about how to get certified whether it's for the business world or for your own satisfaction. Be sure to check out r/linuxadmin for more discussion in the SysAdmin world!\n\n_Please keep questions in r/linuxquestions, r/linux4noobs, or the Wednesday automod thread._", + "author_fullname": "t2_6l4z3", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hm0qct", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.7, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 8, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 8, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594037482.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": "<!-- SC_OFF --><div class='md'><p>Welcome to <a href='/r/linux'>r/linux</a> rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.</p>\n\n<p>Let us know what&#39;s annoying you, whats making you happy, or something that you want to get out to <a href='/r/linux'>r/linux</a> but didn&#39;t make the cut into a full post of it&#39;s own.</p>\n\n<p>For those looking for certifications please use this megathread to ask about how to get certified whether it&#39;s for the business world or for your own satisfaction. Be sure to check out <a href='/r/linuxadmin'>r/linuxadmin</a> for more discussion in the SysAdmin world!</p>\n\n<p><em>Please keep questions in <a href='/r/linuxquestions'>r/linuxquestions</a>, <a href='/r/linux4noobs'>r/linux4noobs</a>, or the Wednesday automod thread.</em></p>\n</div><!-- SC_ON -->", + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": "moderator", + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hm0qct", + "is_robot_indexable": True, + "report_reasons": None, + "author": "AutoModerator", + "discussion_type": None, + "num_comments": 9, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", + "subreddit_subscribers": 544037, + "created_utc": 1594008682.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + ], + "after": "t3_hmytic", + "before": None, + }, +} diff --git a/src/newsreader/news/collection/tests/reddit/builder/tests.py b/src/newsreader/news/collection/tests/reddit/builder/tests.py index 3085199..eb8182a 100644 --- a/src/newsreader/news/collection/tests/reddit/builder/tests.py +++ b/src/newsreader/news/collection/tests/reddit/builder/tests.py @@ -7,16 +7,9 @@ import pytz from newsreader.news.collection.reddit import RedditBuilder from newsreader.news.collection.tests.factories import SubredditFactory -from newsreader.news.collection.tests.reddit.builder.mocks import ( - author_mock, - empty_mock, - simple_mock, - title_mock, - unknown_mock, - unsanitized_mock, -) +from newsreader.news.collection.tests.reddit.builder.mocks import * from newsreader.news.core.models import Post -from newsreader.news.core.tests.factories import PostFactory +from newsreader.news.core.tests.factories import RedditPostFactory class RedditBuilderTestCase(TestCase): @@ -92,10 +85,8 @@ class RedditBuilderTestCase(TestCase): def test_update_posts(self): subreddit = SubredditFactory() - existing_publication_date = pytz.utc.localize(datetime(2020, 7, 8, 14, 0, 0)) - existing_post = PostFactory( + existing_post = RedditPostFactory( remote_identifier="hngsj8", - publication_date=existing_publication_date, author="Old author", title="Old title", body="Old body", @@ -183,3 +174,48 @@ class RedditBuilderTestCase(TestCase): post.title, 'Board statement on the LibreOffice 7.0 RC "Personal EditionBoard statement on the LibreOffice 7.0 RC "Personal Edition" label" labelBoard statement on the LibreOffice 7.0 RC "PersBoard statement on t…', ) + + def test_duplicate_in_response(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((duplicate_mock, mock_stream)) as builder: + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertEquals(Post.objects.count(), 2) + self.assertCountEqual(("hm0qct", "hna75r"), posts.keys()) + + def test_duplicate_in_database(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + duplicate_post = RedditPostFactory( + remote_identifier="hm0qct", rule=subreddit, title="foo" + ) + + with builder((simple_mock, mock_stream)) as builder: + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertEquals(Post.objects.count(), 5) + self.assertCountEqual( + ("hm0qct", "hna75r", "hngs71", "hngsj8", "hnd7cy"), posts.keys() + ) + + duplicate_post.refresh_from_db() + + self.assertEquals( + duplicate_post.publication_date, + pytz.utc.localize(datetime(2020, 7, 6, 6, 11, 22)), + ) + self.assertEquals( + duplicate_post.title, + "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", + ) diff --git a/src/newsreader/news/core/tests/factories.py b/src/newsreader/news/core/tests/factories.py index 46eeeae..966e70b 100644 --- a/src/newsreader/news/core/tests/factories.py +++ b/src/newsreader/news/core/tests/factories.py @@ -1,7 +1,9 @@ import factory +import factory.fuzzy import pytz from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.reddit import REDDIT_URL from newsreader.news.core.models import Category, Post @@ -19,7 +21,7 @@ class PostFactory(factory.django.DjangoModelFactory): author = factory.Faker("name") publication_date = factory.Faker("date_time_this_year", tzinfo=pytz.utc) url = factory.Faker("url") - remote_identifier = factory.Faker("url") + remote_identifier = factory.Faker("uuid4") rule = factory.SubFactory( "newsreader.news.collection.tests.factories.CollectionRuleFactory" @@ -29,3 +31,11 @@ class PostFactory(factory.django.DjangoModelFactory): class Meta: model = Post + + +class RedditPostFactory(PostFactory): + remote_identifier = factory.Faker("uuid4") + url = factory.fuzzy.FuzzyText(length=10, prefix=f"{REDDIT_URL}/") + rule = factory.SubFactory( + "newsreader.news.collection.tests.factories.SubredditFactory" + ) From 21dff8eb155c7879655721ce915a8537eed3cd8c Mon Sep 17 00:00:00 2001 From: Sonny Date: Tue, 21 Jul 2020 23:03:16 +0200 Subject: [PATCH 126/422] Show reddit images/videos or a direct url whenever possbile --- src/newsreader/news/collection/reddit.py | 72 +- .../collection/tests/reddit/builder/mocks.py | 2191 ++++++++++++++++- .../collection/tests/reddit/builder/tests.py | 203 +- .../scss/components/post/_post.scss | 11 +- 4 files changed, 2440 insertions(+), 37 deletions(-) diff --git a/src/newsreader/news/collection/reddit.py b/src/newsreader/news/collection/reddit.py index 1e2837b..5b54762 100644 --- a/src/newsreader/news/collection/reddit.py +++ b/src/newsreader/news/collection/reddit.py @@ -42,6 +42,12 @@ REDDIT_API_URL = "https://oauth.reddit.com" RATE_LIMIT = 60 RATE_LIMIT_DURATION = timedelta(seconds=60) +REDDIT_IMAGE_EXTENSIONS = (".jpg", ".png", ".gif") +REDDIT_VIDEO_EXTENSIONS = (".mp4", ".gifv", ".webm") + +# see type prefixes on https://www.reddit.com/dev/api/ +REDDIT_POST = "t3" + def get_reddit_authorization_url(user): state = str(uuid4()) @@ -114,30 +120,54 @@ class RedditBuilder(Builder): results = {} for post in posts: - if not "data" in post: + if not "data" in post or post["kind"] != REDDIT_POST: continue - remote_identifier = post["data"]["id"] - title = truncate_text(Post, "title", post["data"]["title"]) - author = truncate_text(Post, "author", post["data"]["author"]) - url_fragment = f"{post['data']['permalink']}" + data = post["data"] + + remote_identifier = data["id"] + title = truncate_text(Post, "title", data["title"]) + author = truncate_text(Post, "author", data["author"]) + post_url_fragment = data["permalink"] + direct_url = data["url"] + is_text_post = data["is_self"] if remote_identifier in results: continue - uncleaned_body = post["data"]["selftext_html"] - unescaped_body = unescape(uncleaned_body) if uncleaned_body else "" - body = ( - bleach.clean( - unescaped_body, - tags=WHITELISTED_TAGS, - attributes=WHITELISTED_ATTRIBUTES, - strip=True, - strip_comments=True, + if is_text_post: + uncleaned_body = data["selftext_html"] + unescaped_body = unescape(uncleaned_body) if uncleaned_body else "" + body = ( + bleach.clean( + unescaped_body, + tags=WHITELISTED_TAGS, + attributes=WHITELISTED_ATTRIBUTES, + strip=True, + strip_comments=True, + ) + if unescaped_body + else "" ) - if unescaped_body - else "" - ) + elif direct_url.endswith(REDDIT_IMAGE_EXTENSIONS): + body = f'
      {title}
      ' + elif data["is_video"]: + video_info = data["secure_media"]["reddit_video"] + + body = f'
      ' + elif direct_url.endswith(REDDIT_VIDEO_EXTENSIONS): + extension = next( + extension.replace(".", "") + for extension in REDDIT_VIDEO_EXTENSIONS + if direct_url.endswith(extension) + ) + + if extension == "gifv": + body = f'
      ' + else: + body = f'
      ' + else: + body = f'' try: parsed_date = datetime.fromtimestamp(post["data"]["created_utc"]) @@ -146,12 +176,12 @@ class RedditBuilder(Builder): logging.warning(f"Failed parsing timestamp from {url_fragment}") created_date = timezone.now() - data = { + post_data = { "remote_identifier": remote_identifier, "title": title, "body": body, "author": author, - "url": f"{REDDIT_URL}{url_fragment}", + "url": f"{REDDIT_URL}{post_url_fragment}", "publication_date": created_date, "rule": rule, } @@ -159,13 +189,13 @@ class RedditBuilder(Builder): if remote_identifier in self.existing_posts: existing_post = self.existing_posts[remote_identifier] - for key, value in data.items(): + for key, value in post_data.items(): setattr(existing_post, key, value) results[existing_post.remote_identifier] = existing_post continue - results[remote_identifier] = Post(**data) + results[remote_identifier] = Post(**post_data) return results.values() diff --git a/src/newsreader/news/collection/tests/reddit/builder/mocks.py b/src/newsreader/news/collection/tests/reddit/builder/mocks.py index fabc802..625ced3 100644 --- a/src/newsreader/news/collection/tests/reddit/builder/mocks.py +++ b/src/newsreader/news/collection/tests/reddit/builder/mocks.py @@ -741,7 +741,7 @@ unsanitized_mock = { "author_flair_richtext": [], "gildings": {}, "content_categories": None, - "is_self": False, + "is_self": True, "mod_note": None, "crosspost_parent_list": [ { @@ -1709,3 +1709,2192 @@ duplicate_mock = { "before": None, }, } + +image_mock = { + "data": { + "after": "t3_hr3mhe", + "before": None, + "children": [ + { + "data": { + "all_awardings": [], + "allow_live_comments": True, + "approved_at_utc": None, + "approved_by": None, + "archived": False, + "author": "SamLynn79", + "author_flair_background_color": None, + "author_flair_css_class": None, + "author_flair_richtext": [], + "author_flair_template_id": None, + "author_flair_text": None, + "author_flair_text_color": None, + "author_flair_type": "text", + "author_fullname": "t2_6c9cj", + "author_patreon_flair": False, + "author_premium": True, + "awarders": [], + "banned_at_utc": None, + "banned_by": None, + "can_gild": True, + "can_mod_post": False, + "category": None, + "clicked": False, + "content_categories": None, + "contest_mode": False, + "created": 1594777552.0, + "created_utc": 1594748752.0, + "discussion_type": None, + "distinguished": None, + "domain": "i.redd.it", + "downs": 0, + "edited": False, + "gilded": 1, + "gildings": {"gid_2": 1}, + "hidden": False, + "hide_score": False, + "id": "hr64xh", + "is_crosspostable": True, + "is_meta": False, + "is_original_content": False, + "is_reddit_media_domain": True, + "is_robot_indexable": True, + "is_self": False, + "is_video": False, + "likes": None, + "link_flair_background_color": "", + "link_flair_css_class": None, + "link_flair_richtext": [], + "link_flair_text": None, + "link_flair_text_color": "dark", + "link_flair_type": "text", + "locked": True, + "media": None, + "media_embed": {}, + "media_only": False, + "mod_note": None, + "mod_reason_by": None, + "mod_reason_title": None, + "mod_reports": [], + "name": "t3_hr64xh", + "no_follow": False, + "num_comments": 579, + "num_crossposts": 2, + "num_reports": None, + "over_18": False, + "parent_whitelist_status": "all_ads", + "permalink": "/r/aww/comments/hr64xh/yall_i_just_cant_this_is_my_son_judah_my_wife_and/", + "pinned": False, + "post_hint": "image", + "preview": { + "enabled": True, + "images": [ + { + "id": "xWBh4hObZx0zmG_IDOHBLNN-_NZzEss2dAgm1sm9p1w", + "resolutions": [ + { + "height": 135, + "url": "https://preview.redd.it/cm2qybia1va51.jpg?width=108&crop=smart&auto=webp&s=5374b8f3dff520eba8cf97b589ebc67206f130dc", + "width": 108, + }, + { + "height": 270, + "url": "https://preview.redd.it/cm2qybia1va51.jpg?width=216&crop=smart&auto=webp&s=09d937a8db6f843d9fd34ee024cdfc6432dc0a13", + "width": 216, + }, + { + "height": 400, + "url": "https://preview.redd.it/cm2qybia1va51.jpg?width=320&crop=smart&auto=webp&s=9ba3654c12cb54f6d9c2dce1b07c80ecd6ca9d06", + "width": 320, + }, + { + "height": 800, + "url": "https://preview.redd.it/cm2qybia1va51.jpg?width=640&crop=smart&auto=webp&s=8c53747ae0f92b65fdd41f3aab60ebb8f8d4b1ca", + "width": 640, + }, + { + "height": 1200, + "url": "https://preview.redd.it/cm2qybia1va51.jpg?width=960&crop=smart&auto=webp&s=5668a626da6cd69e23b6c01587783c6cc5817bea", + "width": 960, + }, + { + "height": 1350, + "url": "https://preview.redd.it/cm2qybia1va51.jpg?width=1080&crop=smart&auto=webp&s=8fdd61aed8718109f3739cb532d96be31192b9a0", + "width": 1080, + }, + ], + "source": { + "height": 1800, + "url": "https://preview.redd.it/cm2qybia1va51.jpg?auto=webp&s=17b817b8d0e35bddc7f605d242cd7d116ef8e235", + "width": 1440, + }, + "variants": {}, + } + ], + }, + "pwls": 6, + "quarantine": False, + "removal_reason": None, + "removed_by": None, + "removed_by_category": None, + "report_reasons": None, + "saved": False, + "score": 23419, + "secure_media": None, + "secure_media_embed": {}, + "selftext": "", + "selftext_html": None, + "send_replies": True, + "spoiler": False, + "stickied": False, + "subreddit": "aww", + "subreddit_id": "t5_2qh1o", + "subreddit_name_prefixed": "r/aww", + "subreddit_subscribers": 25634399, + "subreddit_type": "public", + "suggested_sort": None, + "thumbnail": "https://b.thumbs.redditmedia.com/0X39S2jBL66zQCUbJAtlRKeswI8uUxf3-7vmog0VLjc.jpg", + "thumbnail_height": 140, + "thumbnail_width": 140, + "title": "Ya’ll, I just can’t... this is my " + "son, Judah. My wife and I have no " + "idea how we created such a " + "beautiful child.", + "top_awarded_type": None, + "total_awards_received": 4, + "treatment_tags": [], + "ups": 23419, + "upvote_ratio": 0.72, + "url": "https://i.redd.it/cm2qybia1va51.jpg", + "url_overridden_by_dest": "https://i.redd.it/cm2qybia1va51.jpg", + "user_reports": [], + "view_count": None, + "visited": False, + "whitelist_status": "all_ads", + "wls": 6, + }, + "kind": "t3", + }, + { + "data": { + "all_awardings": [], + "allow_live_comments": True, + "approved_at_utc": None, + "approved_by": None, + "archived": False, + "author": "0_GG_0", + "author_flair_background_color": None, + "author_flair_css_class": None, + "author_flair_richtext": [], + "author_flair_template_id": None, + "author_flair_text": None, + "author_flair_text_color": None, + "author_flair_type": "text", + "author_fullname": "t2_70k94sn8", + "author_patreon_flair": False, + "author_premium": True, + "awarders": [], + "banned_at_utc": None, + "banned_by": None, + "can_gild": True, + "can_mod_post": False, + "category": None, + "clicked": False, + "content_categories": None, + "contest_mode": False, + "created": 1594771808.0, + "created_utc": 1594743008.0, + "discussion_type": None, + "distinguished": None, + "domain": "i.redd.it", + "downs": 0, + "edited": False, + "gilded": 0, + "gildings": {}, + "hidden": False, + "hide_score": False, + "id": "hr4bxo", + "is_crosspostable": True, + "is_meta": False, + "is_original_content": False, + "is_reddit_media_domain": True, + "is_robot_indexable": True, + "is_self": False, + "is_video": False, + "likes": None, + "link_flair_background_color": "", + "link_flair_css_class": "lc", + "link_flair_richtext": [], + "link_flair_text": None, + "link_flair_text_color": "dark", + "link_flair_type": "text", + "locked": False, + "media": None, + "media_embed": {}, + "media_only": False, + "mod_note": None, + "mod_reason_by": None, + "mod_reason_title": None, + "mod_reports": [], + "name": "t3_hr4bxo", + "no_follow": False, + "num_comments": 248, + "num_crossposts": 4, + "num_reports": None, + "over_18": False, + "parent_whitelist_status": "all_ads", + "permalink": "/r/aww/comments/hr4bxo/just_thought_yall_would_enjoy_my_goat_dressed_as/", + "pinned": False, + "post_hint": "image", + "preview": { + "enabled": True, + "images": [ + { + "id": "TSXyc6ZJGdCcHk7-wuWnJdVpqsa_t8hmVd4k_e3ofCA", + "resolutions": [ + { + "height": 144, + "url": "https://preview.redd.it/4udujbu6kua51.jpg?width=108&crop=smart&auto=webp&s=ed5a11a7637acc66de48e30fd51d5019fa0c69f7", + "width": 108, + }, + { + "height": 288, + "url": "https://preview.redd.it/4udujbu6kua51.jpg?width=216&crop=smart&auto=webp&s=a812bec268d8ea31dbb9dfe696e0798490538f5a", + "width": 216, + }, + { + "height": 426, + "url": "https://preview.redd.it/4udujbu6kua51.jpg?width=320&crop=smart&auto=webp&s=1be4e3bdea19243b0a627bacb4c9e04f2d3569a7", + "width": 320, + }, + { + "height": 853, + "url": "https://preview.redd.it/4udujbu6kua51.jpg?width=640&crop=smart&auto=webp&s=e73755c3f0b27bb0435d07aa60b32e091bed7957", + "width": 640, + }, + { + "height": 1280, + "url": "https://preview.redd.it/4udujbu6kua51.jpg?width=960&crop=smart&auto=webp&s=8ab6972fffc4786503284a0253e91e9104f2d01e", + "width": 960, + }, + { + "height": 1440, + "url": "https://preview.redd.it/4udujbu6kua51.jpg?width=1080&crop=smart&auto=webp&s=a1e554889179a7599786985679304fda706d83d6", + "width": 1080, + }, + ], + "source": { + "height": 4032, + "url": "https://preview.redd.it/4udujbu6kua51.jpg?auto=webp&s=3eefdef653e0a3a8a10090b804f0888ee6a1a163", + "width": 3024, + }, + "variants": {}, + } + ], + }, + "pwls": 6, + "quarantine": False, + "removal_reason": None, + "removed_by": None, + "removed_by_category": None, + "report_reasons": None, + "saved": False, + "score": 16684, + "secure_media": None, + "secure_media_embed": {}, + "selftext": "", + "selftext_html": None, + "send_replies": True, + "spoiler": False, + "stickied": False, + "subreddit": "aww", + "subreddit_id": "t5_2qh1o", + "subreddit_name_prefixed": "r/aww", + "subreddit_subscribers": 25634399, + "subreddit_type": "public", + "suggested_sort": None, + "thumbnail": "https://b.thumbs.redditmedia.com/h3Ylp4kb0uJzAsST4ZZGsGN8WGxK4wjK2XrM9uUH5uc.jpg", + "thumbnail_height": 140, + "thumbnail_width": 140, + "title": "Just thought y’all would enjoy my " + "goat dressed as a tractor", + "top_awarded_type": None, + "total_awards_received": 2, + "treatment_tags": [], + "ups": 16684, + "upvote_ratio": 0.98, + "url": "https://i.redd.it/4udujbu6kua51.jpg", + "url_overridden_by_dest": "https://i.redd.it/4udujbu6kua51.jpg", + "user_reports": [], + "view_count": None, + "visited": False, + "whitelist_status": "all_ads", + "wls": 6, + }, + "kind": "t3", + }, + { + "data": { + "all_awardings": [], + "allow_live_comments": True, + "approved_at_utc": None, + "approved_by": None, + "archived": False, + "author": "Mechanic619", + "author_flair_background_color": None, + "author_flair_css_class": None, + "author_flair_richtext": [], + "author_flair_template_id": None, + "author_flair_text": None, + "author_flair_text_color": None, + "author_flair_type": "text", + "author_fullname": "t2_4ptrdtz5", + "author_patreon_flair": False, + "author_premium": False, + "awarders": [], + "banned_at_utc": None, + "banned_by": None, + "can_gild": True, + "can_mod_post": False, + "category": None, + "clicked": False, + "content_categories": None, + "contest_mode": False, + "created": 1594760700.0, + "created_utc": 1594731900.0, + "discussion_type": None, + "distinguished": None, + "domain": "i.redd.it", + "downs": 0, + "edited": False, + "gilded": 0, + "gildings": {"gid_1": 1}, + "hidden": False, + "hide_score": False, + "id": "hr14y5", + "is_crosspostable": True, + "is_meta": False, + "is_original_content": False, + "is_reddit_media_domain": True, + "is_robot_indexable": True, + "is_self": False, + "is_video": False, + "likes": None, + "link_flair_background_color": "", + "link_flair_css_class": "lc", + "link_flair_richtext": [], + "link_flair_text": None, + "link_flair_text_color": "dark", + "link_flair_type": "text", + "locked": False, + "media": None, + "media_embed": {}, + "media_only": False, + "mod_note": None, + "mod_reason_by": None, + "mod_reason_title": None, + "mod_reports": [], + "name": "t3_hr14y5", + "no_follow": False, + "num_comments": 1439, + "num_crossposts": 20, + "num_reports": None, + "over_18": False, + "parent_whitelist_status": "all_ads", + "permalink": "/r/aww/comments/hr14y5/mosque_security_on_patrol/", + "pinned": False, + "post_hint": "image", + "preview": { + "enabled": True, + "images": [ + { + "id": "Qs_FmhJgYT8GWyxmDQ8kjBCs_w2V_77cvHvdqLJ7i4s", + "resolutions": [ + { + "height": 135, + "url": "https://preview.redd.it/jk08ge66nta51.jpg?width=108&crop=smart&auto=webp&s=cf4c24ef4f9be86d186c143296bd1e14f15f960a", + "width": 108, + }, + { + "height": 270, + "url": "https://preview.redd.it/jk08ge66nta51.jpg?width=216&crop=smart&auto=webp&s=308e2367a849334c32b579265ed738d9937bed71", + "width": 216, + }, + { + "height": 400, + "url": "https://preview.redd.it/jk08ge66nta51.jpg?width=320&crop=smart&auto=webp&s=bc890f054dc34bb3f8607a70d088926afe113ff1", + "width": 320, + }, + { + "height": 800, + "url": "https://preview.redd.it/jk08ge66nta51.jpg?width=640&crop=smart&auto=webp&s=e23a9bc2d8d1ac6ccefab7f30cfa9def741aaa25", + "width": 640, + }, + { + "height": 1201, + "url": "https://preview.redd.it/jk08ge66nta51.jpg?width=960&crop=smart&auto=webp&s=4d294d1626046d27edc2a281c21ab10502b9ca4c", + "width": 960, + }, + { + "height": 1351, + "url": "https://preview.redd.it/jk08ge66nta51.jpg?width=1080&crop=smart&auto=webp&s=a801e5d9d703204e8b1497d3038d6405b2ed1157", + "width": 1080, + }, + ], + "source": { + "height": 1413, + "url": "https://preview.redd.it/jk08ge66nta51.jpg?auto=webp&s=f4e87e2ad0f0e40ca4f7a08c2a894b234601f3ce", + "width": 1129, + }, + "variants": {}, + } + ], + }, + "pwls": 6, + "quarantine": False, + "removal_reason": None, + "removed_by": None, + "removed_by_category": None, + "report_reasons": None, + "saved": False, + "score": 89133, + "secure_media": None, + "secure_media_embed": {}, + "selftext": "", + "selftext_html": None, + "send_replies": True, + "spoiler": False, + "stickied": False, + "subreddit": "aww", + "subreddit_id": "t5_2qh1o", + "subreddit_name_prefixed": "r/aww", + "subreddit_subscribers": 25634399, + "subreddit_type": "public", + "suggested_sort": None, + "thumbnail": "https://b.thumbs.redditmedia.com/GGHjIElMHDgefR0UdMXVk8CHeDUBhuZMY_QHjls4ynA.jpg", + "thumbnail_height": 140, + "thumbnail_width": 140, + "title": "Mosque security on patrol", + "top_awarded_type": None, + "total_awards_received": 3, + "treatment_tags": [], + "ups": 89133, + "upvote_ratio": 0.93, + "url": "https://i.redd.it/jk08ge66nta51.jpg", + "url_overridden_by_dest": "https://i.redd.it/jk08ge66nta51.jpg", + "user_reports": [], + "view_count": None, + "visited": False, + "whitelist_status": "all_ads", + "wls": 6, + }, + "kind": "t3", + }, + { + "data": { + "all_awardings": [], + "allow_live_comments": False, + "approved_at_utc": None, + "approved_by": None, + "archived": False, + "author": "Amnesia19", + "author_cakeday": True, + "author_flair_background_color": None, + "author_flair_css_class": None, + "author_flair_richtext": [], + "author_flair_template_id": None, + "author_flair_text": None, + "author_flair_text_color": None, + "author_flair_type": "text", + "author_fullname": "t2_1rqe7gk1", + "author_patreon_flair": False, + "author_premium": False, + "awarders": [], + "banned_at_utc": None, + "banned_by": None, + "can_gild": True, + "can_mod_post": False, + "category": None, + "clicked": False, + "content_categories": None, + "contest_mode": False, + "created": 1594765470.0, + "created_utc": 1594736670.0, + "discussion_type": None, + "distinguished": None, + "domain": "i.redd.it", + "downs": 0, + "edited": False, + "gilded": 0, + "gildings": {}, + "hidden": False, + "hide_score": False, + "id": "hr2fv0", + "is_crosspostable": True, + "is_meta": False, + "is_original_content": False, + "is_reddit_media_domain": True, + "is_robot_indexable": True, + "is_self": False, + "is_video": False, + "likes": None, + "link_flair_background_color": "", + "link_flair_css_class": None, + "link_flair_richtext": [], + "link_flair_text": None, + "link_flair_text_color": "dark", + "link_flair_type": "text", + "locked": False, + "media": None, + "media_embed": {}, + "media_only": False, + "mod_note": None, + "mod_reason_by": None, + "mod_reason_title": None, + "mod_reports": [], + "name": "t3_hr2fv0", + "no_follow": False, + "num_comments": 71, + "num_crossposts": 1, + "num_reports": None, + "over_18": False, + "parent_whitelist_status": "all_ads", + "permalink": "/r/aww/comments/hr2fv0/the_look_my_dog_gives_my_grandpa/", + "pinned": False, + "post_hint": "image", + "preview": { + "enabled": True, + "images": [ + { + "id": "v0BbkKy6haXmUxmHz4oXygoR0E-cHkvZDACWL_s7STw", + "resolutions": [ + { + "height": 144, + "url": "https://preview.redd.it/y6q7bgzc1ua51.jpg?width=108&crop=smart&auto=webp&s=4e65e8ff55c02de0ebe79763c91fe43f51216717", + "width": 108, + }, + { + "height": 288, + "url": "https://preview.redd.it/y6q7bgzc1ua51.jpg?width=216&crop=smart&auto=webp&s=e2006e5fe7ac43f911c17dc7f185f33db24e3b52", + "width": 216, + }, + { + "height": 426, + "url": "https://preview.redd.it/y6q7bgzc1ua51.jpg?width=320&crop=smart&auto=webp&s=3dad39d5e48a1b176f7e87b2dd110fb0044b32d7", + "width": 320, + }, + { + "height": 853, + "url": "https://preview.redd.it/y6q7bgzc1ua51.jpg?width=640&crop=smart&auto=webp&s=2f8e86a3feca27a23a72d10b92aba1b79b80f7be", + "width": 640, + }, + { + "height": 1280, + "url": "https://preview.redd.it/y6q7bgzc1ua51.jpg?width=960&crop=smart&auto=webp&s=5ecdd44b728031f8e109f41f99841a1d6c8e86c8", + "width": 960, + }, + { + "height": 1440, + "url": "https://preview.redd.it/y6q7bgzc1ua51.jpg?width=1080&crop=smart&auto=webp&s=49555499040c0ac9958dabd98cbe4e90c054b2a7", + "width": 1080, + }, + ], + "source": { + "height": 4032, + "url": "https://preview.redd.it/y6q7bgzc1ua51.jpg?auto=webp&s=443e98e46a8a096e426ebdc256c45682f46ebe2a", + "width": 3024, + }, + "variants": {}, + } + ], + }, + "pwls": 6, + "quarantine": False, + "removal_reason": None, + "removed_by": None, + "removed_by_category": None, + "report_reasons": None, + "saved": False, + "score": 13614, + "secure_media": None, + "secure_media_embed": {}, + "selftext": "", + "selftext_html": None, + "send_replies": True, + "spoiler": False, + "stickied": False, + "subreddit": "aww", + "subreddit_id": "t5_2qh1o", + "subreddit_name_prefixed": "r/aww", + "subreddit_subscribers": 25634399, + "subreddit_type": "public", + "suggested_sort": None, + "thumbnail": "https://b.thumbs.redditmedia.com/RWRuGJ7ZyBtjO6alY1vbc65TQzgng8RFRWnPG7WUkhE.jpg", + "thumbnail_height": 140, + "thumbnail_width": 140, + "title": "The look my dog gives my grandpa", + "top_awarded_type": None, + "total_awards_received": 0, + "treatment_tags": [], + "ups": 13614, + "upvote_ratio": 0.99, + "url": "https://i.redd.it/y6q7bgzc1ua51.jpg", + "url_overridden_by_dest": "https://i.redd.it/y6q7bgzc1ua51.jpg", + "user_reports": [], + "view_count": None, + "visited": False, + "whitelist_status": "all_ads", + "wls": 6, + }, + "kind": "t3", + }, + ], + "dist": 25, + "modhash": None, + }, + "kind": "Listing", +} + +external_image_mock = { + "data": { + "after": "t3_hr3mhe", + "before": None, + "children": [ + { + "data": { + "all_awardings": [], + "allow_live_comments": False, + "approved_at_utc": None, + "approved_by": None, + "archived": False, + "author": "Captainbuttsreads", + "author_flair_background_color": None, + "author_flair_css_class": None, + "author_flair_richtext": [], + "author_flair_template_id": None, + "author_flair_text": None, + "author_flair_text_color": None, + "author_flair_type": "text", + "author_fullname": "t2_5qaat4af", + "author_patreon_flair": False, + "author_premium": False, + "awarders": [], + "banned_at_utc": None, + "banned_by": None, + "can_gild": True, + "can_mod_post": False, + "category": None, + "clicked": False, + "content_categories": None, + "contest_mode": False, + "created": 1594770844.0, + "created_utc": 1594742044.0, + "crosspost_parent": "t3_gc6eq2", + "crosspost_parent_list": [], + "discussion_type": None, + "distinguished": None, + "domain": "gfycat.com", + "downs": 0, + "edited": False, + "gilded": 0, + "gildings": {}, + "hidden": False, + "hide_score": False, + "id": "hr41am", + "is_crosspostable": True, + "is_meta": False, + "is_original_content": False, + "is_reddit_media_domain": False, + "is_robot_indexable": True, + "is_self": False, + "is_video": False, + "likes": None, + "link_flair_background_color": "", + "link_flair_css_class": None, + "link_flair_richtext": [], + "link_flair_text": None, + "link_flair_text_color": "dark", + "link_flair_type": "text", + "locked": False, + "media": None, + "media_embed": {}, + "media_only": False, + "mod_note": None, + "mod_reason_by": None, + "mod_reason_title": None, + "mod_reports": [], + "name": "t3_hr41am", + "no_follow": False, + "num_comments": 45, + "num_crossposts": 0, + "num_reports": None, + "over_18": False, + "parent_whitelist_status": "all_ads", + "permalink": "/r/aww/comments/hr41am/excited_cows_have_a_new_brush/", + "pinned": False, + "post_hint": "link", + "preview": { + "enabled": False, + "images": [ + { + "id": "l5tVSe6B4QDc7wk6Z9WfCXr20D_rAOHerf6i0N53nNc", + "resolutions": [ + { + "height": 108, + "url": "https://external-preview.redd.it/R51JAzaGbva91vYxn9uWL3NwCzWJW5mrdVxb1idjtBg.jpg?width=108&crop=smart&auto=webp&s=f908e1fb9403194a31f9a0c1f056f59e0718201e", + "width": 108, + }, + { + "height": 216, + "url": "https://external-preview.redd.it/R51JAzaGbva91vYxn9uWL3NwCzWJW5mrdVxb1idjtBg.jpg?width=216&crop=smart&auto=webp&s=de377df68832a52419d83c06ea74a13de28b96e0", + "width": 216, + }, + ], + "source": { + "height": 250, + "url": "https://external-preview.redd.it/R51JAzaGbva91vYxn9uWL3NwCzWJW5mrdVxb1idjtBg.jpg?auto=webp&s=b4166cb5a350e6d0197381cdf8db702f8a760493", + "width": 250, + }, + "variants": {}, + } + ], + "reddit_video_preview": { + "dash_url": "https://v.redd.it/mimyo7z6ppa51/DASHPlaylist.mpd", + "duration": 33, + "fallback_url": "https://v.redd.it/mimyo7z6ppa51/DASH_480.mp4", + "height": 640, + "hls_url": "https://v.redd.it/mimyo7z6ppa51/HLSPlaylist.m3u8", + "is_gif": True, + "scrubber_media_url": "https://v.redd.it/mimyo7z6ppa51/DASH_96.mp4", + "transcoding_status": "completed", + "width": 640, + }, + }, + "pwls": 6, + "quarantine": False, + "removal_reason": None, + "removed_by": None, + "removed_by_category": None, + "report_reasons": None, + "saved": False, + "score": 3219, + "secure_media": None, + "secure_media_embed": {}, + "selftext": "", + "selftext_html": None, + "send_replies": False, + "spoiler": False, + "stickied": False, + "subreddit": "aww", + "subreddit_id": "t5_2qh1o", + "subreddit_name_prefixed": "r/aww", + "subreddit_subscribers": 25634399, + "subreddit_type": "public", + "suggested_sort": None, + "thumbnail": "https://b.thumbs.redditmedia.com/NKTwvIU2xxoOMpzYNlYYstS2586x64Gi--52N0M-OJY.jpg", + "thumbnail_height": 140, + "thumbnail_width": 140, + "title": "Excited cows have a new brush!", + "top_awarded_type": None, + "total_awards_received": 0, + "treatment_tags": [], + "ups": 3219, + "upvote_ratio": 0.99, + "url": "http://gfycat.com/thatalivedogwoodclubgall", + "url_overridden_by_dest": "http://gfycat.com/thatalivedogwoodclubgall", + "user_reports": [], + "view_count": None, + "visited": False, + "whitelist_status": "all_ads", + "wls": 6, + }, + "kind": "t3", + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "aww", + "selftext": "", + "author_fullname": "t2_78ni2", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Novosibirsk Zoo welcomes 16 cobalt-eyed Pallas’s cat kittens", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/aww", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "thumbnail_height": 93, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_huoldn", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.99, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 1933, + "total_awards_received": 0, + "media_embed": {}, + "thumbnail_width": 140, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 1933, + "approved_by": None, + "author_premium": False, + "thumbnail": "https://a.thumbs.redditmedia.com/j-D-Z79QQ6tGk0E3SGdb8GzqbLVUY3lu59tDaXbOYl8.jpg", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "post_hint": "image", + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1595292144, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.imgur.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.imgur.com/usfMVUJ.jpg", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": False, + "pinned": False, + "over_18": False, + "preview": { + "images": [ + { + "source": { + "url": "https://external-preview.redd.it/iPfDCL-dfB4_TmnGsMEXWJMzxXmBEMPuV5ejLkYBNVo.jpg?auto=webp&s=2126d34a0134efa94ecab03917944709c8bc3305", + "width": 1024, + "height": 682, + }, + "resolutions": [ + { + "url": "https://external-preview.redd.it/iPfDCL-dfB4_TmnGsMEXWJMzxXmBEMPuV5ejLkYBNVo.jpg?width=108&crop=smart&auto=webp&s=710a44f787b98a0a37ca543b7428917ee55b3c46", + "width": 108, + "height": 71, + }, + { + "url": "https://external-preview.redd.it/iPfDCL-dfB4_TmnGsMEXWJMzxXmBEMPuV5ejLkYBNVo.jpg?width=216&crop=smart&auto=webp&s=b1bcdd7734a3a569f99fa88c6be9447105e58276", + "width": 216, + "height": 143, + }, + { + "url": "https://external-preview.redd.it/iPfDCL-dfB4_TmnGsMEXWJMzxXmBEMPuV5ejLkYBNVo.jpg?width=320&crop=smart&auto=webp&s=1671bf09a7b73d0ca51cf2de884b37d6a3591d6a", + "width": 320, + "height": 213, + }, + { + "url": "https://external-preview.redd.it/iPfDCL-dfB4_TmnGsMEXWJMzxXmBEMPuV5ejLkYBNVo.jpg?width=640&crop=smart&auto=webp&s=9fcdddbaeaad13273e0b53a862c73c4fee9f7e3d", + "width": 640, + "height": 426, + }, + { + "url": "https://external-preview.redd.it/iPfDCL-dfB4_TmnGsMEXWJMzxXmBEMPuV5ejLkYBNVo.jpg?width=960&crop=smart&auto=webp&s=e531480236c0ae72b78f27dd88f2cedc9f73cccc", + "width": 960, + "height": 639, + }, + ], + "variants": {}, + "id": "oJ9pHVA-JhoodtgNlku8ZQv8FhtadS2r36wGLAriUtY", + } + ], + "enabled": True, + }, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": False, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1o", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "huoldn", + "is_robot_indexable": True, + "report_reasons": None, + "author": "Ben_zyl", + "discussion_type": None, + "num_comments": 20, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/aww/comments/huoldn/novosibirsk_zoo_welcomes_16_cobalteyed_pallass/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.imgur.com/usfMVUJ.jpg", + "subreddit_subscribers": 25723833, + "created_utc": 1595263344, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + ], + "dist": 25, + "modhash": None, + }, + "kind": "Listing", +} + +video_mock = { + "data": { + "after": "t3_hr3mhe", + "before": None, + "children": [ + { + "data": { + "all_awardings": [], + "allow_live_comments": False, + "approved_at_utc": None, + "approved_by": None, + "archived": False, + "author": "TommyLondoner", + "author_flair_background_color": None, + "author_flair_css_class": None, + "author_flair_richtext": [], + "author_flair_template_id": None, + "author_flair_text": None, + "author_flair_text_color": None, + "author_flair_type": "text", + "author_fullname": "t2_75bis9gi", + "author_patreon_flair": False, + "author_premium": True, + "awarders": [], + "banned_at_utc": None, + "banned_by": None, + "can_gild": True, + "can_mod_post": False, + "category": None, + "clicked": False, + "content_categories": None, + "contest_mode": False, + "created": 1594767660.0, + "created_utc": 1594738860.0, + "discussion_type": None, + "distinguished": None, + "domain": "v.redd.it", + "downs": 0, + "edited": False, + "gilded": 1, + "gildings": {"gid_2": 1}, + "hidden": False, + "hide_score": False, + "id": "hr32jf", + "is_crosspostable": True, + "is_meta": False, + "is_original_content": False, + "is_reddit_media_domain": True, + "is_robot_indexable": True, + "is_self": False, + "is_video": True, + "likes": None, + "link_flair_background_color": "", + "link_flair_css_class": "lc", + "link_flair_richtext": [], + "link_flair_text": None, + "link_flair_text_color": "dark", + "link_flair_type": "text", + "locked": False, + "media": { + "reddit_video": { + "dash_url": "https://v.redd.it/9avhmd5s7ua51/DASHPlaylist.mpd?a=1597351258%2CODVjMjcyMDkzOWE1NDBiNzUwNzVhNDUwYmE0MGNiNzk5MGRmZmZmMzBhZjIzNDAzYzczY2NkNzRjNTgyMjAzNQ%3D%3D&v=1&f=sd", + "duration": 78, + "fallback_url": "https://v.redd.it/9avhmd5s7ua51/DASH_360.mp4?source=fallback", + "height": 428, + "hls_url": "https://v.redd.it/9avhmd5s7ua51/HLSPlaylist.m3u8?a=1597351258%2CNjE4YTA0NjUwZWNmNjhjNTRhNmU4ZjBmNDMyYWYxOGYzZTNkZWM2YjViM2I2ZDZjZWNhYzY0ZGVmOWU0Y2EyYg%3D%3D&v=1&f=sd", + "is_gif": False, + "scrubber_media_url": "https://v.redd.it/9avhmd5s7ua51/DASH_96.mp4", + "transcoding_status": "completed", + "width": 258, + } + }, + "media_embed": {}, + "media_only": False, + "mod_note": None, + "mod_reason_by": None, + "mod_reason_title": None, + "mod_reports": [], + "name": "t3_hr32jf", + "no_follow": False, + "num_comments": 150, + "num_crossposts": 2, + "num_reports": None, + "over_18": False, + "parent_whitelist_status": "all_ads", + "permalink": "/r/aww/comments/hr32jf/this_guy_definitely_loves_his_job/", + "pinned": False, + "post_hint": "hosted:video", + "preview": { + "enabled": False, + "images": [ + { + "id": "dX_mx_ZfJMwVn_pak9ZPQq8rMT_gPkW0_4gOzDxPSHM", + "resolutions": [ + { + "height": 179, + "url": "https://external-preview.redd.it/PMy-Z___DIG6aWnoyEy1VKottxLQFWCRSdHDV1a9N8w.png?width=108&crop=smart&format=pjpg&auto=webp&s=e0b8b68a78a8e9071bf56417ac6589bc8aff7634", + "width": 108, + }, + { + "height": 358, + "url": "https://external-preview.redd.it/PMy-Z___DIG6aWnoyEy1VKottxLQFWCRSdHDV1a9N8w.png?width=216&crop=smart&format=pjpg&auto=webp&s=8668c3c7ccbdacfe3376d8af4b1b49df9d6aec97", + "width": 216, + }, + ], + "source": { + "height": 428, + "url": "https://external-preview.redd.it/PMy-Z___DIG6aWnoyEy1VKottxLQFWCRSdHDV1a9N8w.png?format=pjpg&auto=webp&s=b0b6439fbe01c3f5d1bf1eae54a588cc745d3415", + "width": 258, + }, + "variants": {}, + } + ], + }, + "pwls": 6, + "quarantine": False, + "removal_reason": None, + "removed_by": None, + "removed_by_category": None, + "report_reasons": None, + "saved": False, + "score": 9324, + "secure_media": { + "reddit_video": { + "dash_url": "https://v.redd.it/9avhmd5s7ua51/DASHPlaylist.mpd?a=1597351258%2CODVjMjcyMDkzOWE1NDBiNzUwNzVhNDUwYmE0MGNiNzk5MGRmZmZmMzBhZjIzNDAzYzczY2NkNzRjNTgyMjAzNQ%3D%3D&v=1&f=sd", + "duration": 78, + "fallback_url": "https://v.redd.it/9avhmd5s7ua51/DASH_360.mp4?source=fallback", + "height": 428, + "hls_url": "https://v.redd.it/9avhmd5s7ua51/HLSPlaylist.m3u8?a=1597351258%2CNjE4YTA0NjUwZWNmNjhjNTRhNmU4ZjBmNDMyYWYxOGYzZTNkZWM2YjViM2I2ZDZjZWNhYzY0ZGVmOWU0Y2EyYg%3D%3D&v=1&f=sd", + "is_gif": False, + "scrubber_media_url": "https://v.redd.it/9avhmd5s7ua51/DASH_96.mp4", + "transcoding_status": "completed", + "width": 258, + } + }, + "secure_media_embed": {}, + "selftext": "", + "selftext_html": None, + "send_replies": True, + "spoiler": False, + "stickied": False, + "subreddit": "aww", + "subreddit_id": "t5_2qh1o", + "subreddit_name_prefixed": "r/aww", + "subreddit_subscribers": 25634399, + "subreddit_type": "public", + "suggested_sort": None, + "thumbnail": "https://b.thumbs.redditmedia.com/ibsS3H5xMLDSVglh8NBYJ4cgIsXuqYVLJWbiYVTykXg.jpg", + "thumbnail_height": 140, + "thumbnail_width": 140, + "title": "This guy definitely loves his job !", + "top_awarded_type": None, + "total_awards_received": 1, + "treatment_tags": [], + "ups": 9324, + "upvote_ratio": 0.96, + "url": "https://v.redd.it/9avhmd5s7ua51", + "url_overridden_by_dest": "https://v.redd.it/9avhmd5s7ua51", + "user_reports": [], + "view_count": None, + "visited": False, + "whitelist_status": "all_ads", + "wls": 6, + }, + "kind": "t3", + }, + { + "data": { + "all_awardings": [], + "allow_live_comments": True, + "approved_at_utc": None, + "approved_by": None, + "archived": False, + "author": "LucileEsparza", + "author_flair_background_color": None, + "author_flair_css_class": None, + "author_flair_richtext": [], + "author_flair_template_id": None, + "author_flair_text": None, + "author_flair_text_color": None, + "author_flair_type": "text", + "author_fullname": "t2_5loa1v96", + "author_patreon_flair": False, + "author_premium": False, + "awarders": [], + "banned_at_utc": None, + "banned_by": None, + "can_gild": True, + "can_mod_post": False, + "category": None, + "clicked": False, + "content_categories": None, + "contest_mode": False, + "created": 1594762969.0, + "created_utc": 1594734169.0, + "discussion_type": None, + "distinguished": None, + "domain": "v.redd.it", + "downs": 0, + "edited": False, + "gilded": 0, + "gildings": {}, + "hidden": False, + "hide_score": False, + "id": "hr1r00", + "is_crosspostable": True, + "is_meta": False, + "is_original_content": False, + "is_reddit_media_domain": True, + "is_robot_indexable": True, + "is_self": False, + "is_video": True, + "likes": None, + "link_flair_background_color": "", + "link_flair_css_class": "lc", + "link_flair_richtext": [], + "link_flair_text": None, + "link_flair_text_color": "dark", + "link_flair_type": "text", + "locked": False, + "media": { + "reddit_video": { + "dash_url": "https://v.redd.it/eyvbxaeqtta51/DASHPlaylist.mpd?a=1597351258%2CYjJmMWE3ZGJmM2FhMzVkYzZlNjIzOTAwM2ZmZTBkYjAxMzE0NDY2MDIyNGRhOWViMTViZTE0NTlmMzkzM2JlYg%3D%3D&v=1&f=sd", + "duration": 8, + "fallback_url": "https://v.redd.it/eyvbxaeqtta51/DASH_480.mp4?source=fallback", + "height": 640, + "hls_url": "https://v.redd.it/eyvbxaeqtta51/HLSPlaylist.m3u8?a=1597351258%2CY2JiMmQ0MjliNmE5NTA5MDE3YjAyNmVkYTg2Yjg1YWYwYmJlNDE4ZGM1NjE4ZDU3YjkzYjJlMDE2ZmM4Yzk5MQ%3D%3D&v=1&f=sd", + "is_gif": False, + "scrubber_media_url": "https://v.redd.it/eyvbxaeqtta51/DASH_96.mp4", + "transcoding_status": "completed", + "width": 640, + } + }, + "media_embed": {}, + "media_only": False, + "mod_note": None, + "mod_reason_by": None, + "mod_reason_title": None, + "mod_reports": [], + "name": "t3_hr1r00", + "no_follow": False, + "num_comments": 63, + "num_crossposts": 3, + "num_reports": None, + "over_18": False, + "parent_whitelist_status": "all_ads", + "permalink": "/r/aww/comments/hr1r00/cool_catt_and_his_clingy_girlfriend/", + "pinned": False, + "post_hint": "hosted:video", + "preview": { + "enabled": False, + "images": [ + { + "id": "wrscJ_l9A6Q_Mn1NAg06I4o3W39bbNgTBYg2Xm_Vl8U", + "resolutions": [ + { + "height": 108, + "url": "https://external-preview.redd.it/GkeXMqmDn03NOYgnIk0QDQAS8HfRacPrmYvyTUSv6w0.png?width=108&crop=smart&format=pjpg&auto=webp&s=f285ef95065be8a340e1cb7792d80a9640564eb6", + "width": 108, + }, + { + "height": 216, + "url": "https://external-preview.redd.it/GkeXMqmDn03NOYgnIk0QDQAS8HfRacPrmYvyTUSv6w0.png?width=216&crop=smart&format=pjpg&auto=webp&s=6d26b4f8d7b16f0f02bc6ce6f35af889b43cf026", + "width": 216, + }, + { + "height": 320, + "url": "https://external-preview.redd.it/GkeXMqmDn03NOYgnIk0QDQAS8HfRacPrmYvyTUSv6w0.png?width=320&crop=smart&format=pjpg&auto=webp&s=5d081467da187bd8c24e9c524583513ee6afe388", + "width": 320, + }, + { + "height": 640, + "url": "https://external-preview.redd.it/GkeXMqmDn03NOYgnIk0QDQAS8HfRacPrmYvyTUSv6w0.png?width=640&crop=smart&format=pjpg&auto=webp&s=557369f302f18b35284ffaacaccf09986f755187", + "width": 640, + }, + ], + "source": { + "height": 640, + "url": "https://external-preview.redd.it/GkeXMqmDn03NOYgnIk0QDQAS8HfRacPrmYvyTUSv6w0.png?format=pjpg&auto=webp&s=cb0a79a2effe0323e862fb713dab76b39051afbb", + "width": 640, + }, + "variants": {}, + } + ], + }, + "pwls": 6, + "quarantine": False, + "removal_reason": None, + "removed_by": None, + "removed_by_category": None, + "report_reasons": None, + "saved": False, + "score": 11007, + "secure_media": { + "reddit_video": { + "dash_url": "https://v.redd.it/eyvbxaeqtta51/DASHPlaylist.mpd?a=1597351258%2CYjJmMWE3ZGJmM2FhMzVkYzZlNjIzOTAwM2ZmZTBkYjAxMzE0NDY2MDIyNGRhOWViMTViZTE0NTlmMzkzM2JlYg%3D%3D&v=1&f=sd", + "duration": 8, + "fallback_url": "https://v.redd.it/eyvbxaeqtta51/DASH_480.mp4?source=fallback", + "height": 640, + "hls_url": "https://v.redd.it/eyvbxaeqtta51/HLSPlaylist.m3u8?a=1597351258%2CY2JiMmQ0MjliNmE5NTA5MDE3YjAyNmVkYTg2Yjg1YWYwYmJlNDE4ZGM1NjE4ZDU3YjkzYjJlMDE2ZmM4Yzk5MQ%3D%3D&v=1&f=sd", + "is_gif": False, + "scrubber_media_url": "https://v.redd.it/eyvbxaeqtta51/DASH_96.mp4", + "transcoding_status": "completed", + "width": 640, + } + }, + "secure_media_embed": {}, + "selftext": "", + "selftext_html": None, + "send_replies": False, + "spoiler": False, + "stickied": False, + "subreddit": "aww", + "subreddit_id": "t5_2qh1o", + "subreddit_name_prefixed": "r/aww", + "subreddit_subscribers": 25634399, + "subreddit_type": "public", + "suggested_sort": None, + "thumbnail": "https://b.thumbs.redditmedia.com/WSBiDcoWPwAgSkt08uCI6TK7v_tdAdHmQHv7TePyTOs.jpg", + "thumbnail_height": 140, + "thumbnail_width": 140, + "title": "Cool catt and his clingy girlfriend", + "top_awarded_type": None, + "total_awards_received": 1, + "treatment_tags": [], + "ups": 11007, + "upvote_ratio": 0.99, + "url": "https://v.redd.it/eyvbxaeqtta51", + "url_overridden_by_dest": "https://v.redd.it/eyvbxaeqtta51", + "user_reports": [], + "view_count": None, + "visited": False, + "whitelist_status": "all_ads", + "wls": 6, + }, + "kind": "t3", + }, + { + "data": { + "all_awardings": [], + "allow_live_comments": False, + "approved_at_utc": None, + "approved_by": None, + "archived": False, + "author": "memezzer", + "author_flair_background_color": "", + "author_flair_css_class": "k", + "author_flair_richtext": [], + "author_flair_template_id": None, + "author_flair_text": None, + "author_flair_text_color": "dark", + "author_flair_type": "text", + "author_fullname": "t2_41jaebm4", + "author_patreon_flair": False, + "author_premium": True, + "awarders": [], + "banned_at_utc": None, + "banned_by": None, + "can_gild": True, + "can_mod_post": False, + "category": None, + "clicked": False, + "content_categories": None, + "contest_mode": False, + "created": 1594759625.0, + "created_utc": 1594730825.0, + "discussion_type": None, + "distinguished": None, + "domain": "v.redd.it", + "downs": 0, + "edited": False, + "gilded": 0, + "gildings": {}, + "hidden": False, + "hide_score": False, + "id": "hr0uzh", + "is_crosspostable": True, + "is_meta": False, + "is_original_content": False, + "is_reddit_media_domain": True, + "is_robot_indexable": True, + "is_self": False, + "is_video": True, + "likes": None, + "link_flair_background_color": "", + "link_flair_css_class": None, + "link_flair_richtext": [], + "link_flair_text": None, + "link_flair_text_color": "dark", + "link_flair_type": "text", + "locked": False, + "media": { + "reddit_video": { + "dash_url": "https://v.redd.it/y0mavwswjta51/DASHPlaylist.mpd?a=1597351258%2CYjU1NzFjOTE0YzY2OTdmODk3MGRiMGU4MjdhOGE5ODk2YWNiODQyMGUyOWRhNzI1M2U1MTEyZjBhOWZkZTZmMw%3D%3D&v=1&f=sd", + "duration": 8, + "fallback_url": "https://v.redd.it/y0mavwswjta51/DASH_720.mp4?source=fallback", + "height": 960, + "hls_url": "https://v.redd.it/y0mavwswjta51/HLSPlaylist.m3u8?a=1597351258%2CODk4NTdhMzA3NmY2ZmY2NGQxMmI2ZjcyMzk0ZTFhOTdhOGI4NGQ1NjBiMzNiMmVmZDBhMTQ4MGRkOWJlOWU1YQ%3D%3D&v=1&f=sd", + "is_gif": False, + "scrubber_media_url": "https://v.redd.it/y0mavwswjta51/DASH_96.mp4", + "transcoding_status": "completed", + "width": 960, + } + }, + "media_embed": {}, + "media_only": False, + "mod_note": None, + "mod_reason_by": None, + "mod_reason_title": None, + "mod_reports": [], + "name": "t3_hr0uzh", + "no_follow": False, + "num_comments": 86, + "num_crossposts": 3, + "num_reports": None, + "over_18": False, + "parent_whitelist_status": "all_ads", + "permalink": "/r/aww/comments/hr0uzh/good_pillow/", + "pinned": False, + "post_hint": "hosted:video", + "preview": { + "enabled": False, + "images": [ + { + "id": "neoTdGv5lMArlfu6euGUK_v_O87Lfmdrrz1ePTwzp1w", + "resolutions": [ + { + "height": 108, + "url": "https://external-preview.redd.it/E3alyJV8xErhjjDt_Qujcd0ZqmUhGOSFINXALm2FZAQ.png?width=108&crop=smart&format=pjpg&auto=webp&s=dcc1172b7ace007e8c72080519a16a487596d7e2", + "width": 108, + }, + { + "height": 216, + "url": "https://external-preview.redd.it/E3alyJV8xErhjjDt_Qujcd0ZqmUhGOSFINXALm2FZAQ.png?width=216&crop=smart&format=pjpg&auto=webp&s=a7968ce1aa34957a7f7103d06a66d4f9df95d437", + "width": 216, + }, + { + "height": 320, + "url": "https://external-preview.redd.it/E3alyJV8xErhjjDt_Qujcd0ZqmUhGOSFINXALm2FZAQ.png?width=320&crop=smart&format=pjpg&auto=webp&s=a2302d80948fba08e91db0a10db579341e1df712", + "width": 320, + }, + { + "height": 640, + "url": "https://external-preview.redd.it/E3alyJV8xErhjjDt_Qujcd0ZqmUhGOSFINXALm2FZAQ.png?width=640&crop=smart&format=pjpg&auto=webp&s=a8487450d38d14bcdfda2aeb659b453d8b1cacab", + "width": 640, + }, + { + "height": 960, + "url": "https://external-preview.redd.it/E3alyJV8xErhjjDt_Qujcd0ZqmUhGOSFINXALm2FZAQ.png?width=960&crop=smart&format=pjpg&auto=webp&s=d371bee68cab49130babe4b890c6323db128c214", + "width": 960, + }, + ], + "source": { + "height": 960, + "url": "https://external-preview.redd.it/E3alyJV8xErhjjDt_Qujcd0ZqmUhGOSFINXALm2FZAQ.png?format=pjpg&auto=webp&s=ff90de8f0a693afeca69dc85dbecb6af9783c769", + "width": 960, + }, + "variants": {}, + } + ], + }, + "pwls": 6, + "quarantine": False, + "removal_reason": None, + "removed_by": None, + "removed_by_category": None, + "report_reasons": None, + "saved": False, + "score": 13271, + "secure_media": { + "reddit_video": { + "dash_url": "https://v.redd.it/y0mavwswjta51/DASHPlaylist.mpd?a=1597351258%2CYjU1NzFjOTE0YzY2OTdmODk3MGRiMGU4MjdhOGE5ODk2YWNiODQyMGUyOWRhNzI1M2U1MTEyZjBhOWZkZTZmMw%3D%3D&v=1&f=sd", + "duration": 8, + "fallback_url": "https://v.redd.it/y0mavwswjta51/DASH_720.mp4?source=fallback", + "height": 960, + "hls_url": "https://v.redd.it/y0mavwswjta51/HLSPlaylist.m3u8?a=1597351258%2CODk4NTdhMzA3NmY2ZmY2NGQxMmI2ZjcyMzk0ZTFhOTdhOGI4NGQ1NjBiMzNiMmVmZDBhMTQ4MGRkOWJlOWU1YQ%3D%3D&v=1&f=sd", + "is_gif": False, + "scrubber_media_url": "https://v.redd.it/y0mavwswjta51/DASH_96.mp4", + "transcoding_status": "completed", + "width": 960, + } + }, + "secure_media_embed": {}, + "selftext": "", + "selftext_html": None, + "send_replies": True, + "spoiler": False, + "stickied": False, + "subreddit": "aww", + "subreddit_id": "t5_2qh1o", + "subreddit_name_prefixed": "r/aww", + "subreddit_subscribers": 25634399, + "subreddit_type": "public", + "suggested_sort": "confidence", + "thumbnail": "https://b.thumbs.redditmedia.com/sxFESWCVsSf4ij5_-a1xdJaFhSU2MjJ5T_TVFbook6Q.jpg", + "thumbnail_height": 140, + "thumbnail_width": 140, + "title": "Good pillow", + "top_awarded_type": None, + "total_awards_received": 0, + "treatment_tags": [], + "ups": 13271, + "upvote_ratio": 0.99, + "url": "https://v.redd.it/y0mavwswjta51", + "url_overridden_by_dest": "https://v.redd.it/y0mavwswjta51", + "user_reports": [], + "view_count": None, + "visited": False, + "whitelist_status": "all_ads", + "wls": 6, + }, + "kind": "t3", + }, + { + "data": { + "all_awardings": [], + "allow_live_comments": True, + "approved_at_utc": None, + "approved_by": None, + "archived": False, + "author": "asdfpartyy", + "author_flair_background_color": None, + "author_flair_css_class": None, + "author_flair_richtext": [], + "author_flair_template_id": None, + "author_flair_text": None, + "author_flair_text_color": None, + "author_flair_type": "text", + "author_fullname": "t2_t0ay0", + "author_patreon_flair": False, + "author_premium": True, + "awarders": [], + "banned_at_utc": None, + "banned_by": None, + "can_gild": True, + "can_mod_post": False, + "category": None, + "clicked": False, + "content_categories": None, + "contest_mode": False, + "created": 1594745472.0, + "created_utc": 1594716672.0, + "discussion_type": None, + "distinguished": None, + "domain": "v.redd.it", + "downs": 0, + "edited": False, + "gilded": 1, + "gildings": {"gid_2": 1}, + "hidden": False, + "hide_score": False, + "id": "hqy0ny", + "is_crosspostable": True, + "is_meta": False, + "is_original_content": False, + "is_reddit_media_domain": True, + "is_robot_indexable": True, + "is_self": False, + "is_video": True, + "likes": None, + "link_flair_background_color": "", + "link_flair_css_class": None, + "link_flair_richtext": [], + "link_flair_text": None, + "link_flair_text_color": "dark", + "link_flair_type": "text", + "locked": False, + "media": { + "reddit_video": { + "dash_url": "https://v.redd.it/asj4p03rdsa51/DASHPlaylist.mpd?a=1597351258%2CY2VmYTAyMWNmZjIwZjQ4YTBmMDc5MTRjOTU0NjliZWU3MDE2YTU3NjJiYzQxZWRiODY4ZTc1YWI1NDY4MWIxNA%3D%3D&v=1&f=sd", + "duration": 30, + "fallback_url": "https://v.redd.it/asj4p03rdsa51/DASH_360.mp4?source=fallback", + "height": 360, + "hls_url": "https://v.redd.it/asj4p03rdsa51/HLSPlaylist.m3u8?a=1597351258%2CY2QxM2I4Njk5MmIyOTRiZTBhNDQ2MDg0ZTM2NTllYzBjODBlYjNiNDc1Mzg2ODIxNDk4MTAzMzYyNzlmNjI1NQ%3D%3D&v=1&f=sd", + "is_gif": False, + "scrubber_media_url": "https://v.redd.it/asj4p03rdsa51/DASH_96.mp4", + "transcoding_status": "completed", + "width": 640, + } + }, + "media_embed": {}, + "media_only": False, + "mod_note": None, + "mod_reason_by": None, + "mod_reason_title": None, + "mod_reports": [], + "name": "t3_hqy0ny", + "no_follow": False, + "num_comments": 849, + "num_crossposts": 24, + "num_reports": None, + "over_18": False, + "parent_whitelist_status": "all_ads", + "permalink": "/r/aww/comments/hqy0ny/bunnies_flop_over_when_they_feel_completely_safe/", + "pinned": False, + "post_hint": "hosted:video", + "preview": { + "enabled": False, + "images": [ + { + "id": "eMi5JzdWDMeDALsqK8bVceX3jbXTWS_S1D-Ie1hQxnc", + "resolutions": [ + { + "height": 60, + "url": "https://external-preview.redd.it/e67OvQTnkypoYCuC4ApCsgdzVGjPLX3986XW_Ttm9bU.png?width=108&crop=smart&format=pjpg&auto=webp&s=5c6d61e0d4934df3c1f4b7a4c3c3afdd4c31c037", + "width": 108, + }, + { + "height": 121, + "url": "https://external-preview.redd.it/e67OvQTnkypoYCuC4ApCsgdzVGjPLX3986XW_Ttm9bU.png?width=216&crop=smart&format=pjpg&auto=webp&s=24586000b5821e23ce78f395c1f294bbe3fa3945", + "width": 216, + }, + { + "height": 180, + "url": "https://external-preview.redd.it/e67OvQTnkypoYCuC4ApCsgdzVGjPLX3986XW_Ttm9bU.png?width=320&crop=smart&format=pjpg&auto=webp&s=dcaed0109703cbddd4914e138afdb61086cffd81", + "width": 320, + }, + { + "height": 360, + "url": "https://external-preview.redd.it/e67OvQTnkypoYCuC4ApCsgdzVGjPLX3986XW_Ttm9bU.png?width=640&crop=smart&format=pjpg&auto=webp&s=ef4f6dc33fe582b93e954114e9eb1447bbbc197b", + "width": 640, + }, + ], + "source": { + "height": 360, + "url": "https://external-preview.redd.it/e67OvQTnkypoYCuC4ApCsgdzVGjPLX3986XW_Ttm9bU.png?format=pjpg&auto=webp&s=b6e8cba9d25c684ecb7104c1e1c454dba7fd3f2f", + "width": 640, + }, + "variants": {}, + } + ], + }, + "pwls": 6, + "quarantine": False, + "removal_reason": None, + "removed_by": None, + "removed_by_category": None, + "report_reasons": None, + "saved": False, + "score": 112661, + "secure_media": { + "reddit_video": { + "dash_url": "https://v.redd.it/asj4p03rdsa51/DASHPlaylist.mpd?a=1597351258%2CY2VmYTAyMWNmZjIwZjQ4YTBmMDc5MTRjOTU0NjliZWU3MDE2YTU3NjJiYzQxZWRiODY4ZTc1YWI1NDY4MWIxNA%3D%3D&v=1&f=sd", + "duration": 30, + "fallback_url": "https://v.redd.it/asj4p03rdsa51/DASH_360.mp4?source=fallback", + "height": 360, + "hls_url": "https://v.redd.it/asj4p03rdsa51/HLSPlaylist.m3u8?a=1597351258%2CY2QxM2I4Njk5MmIyOTRiZTBhNDQ2MDg0ZTM2NTllYzBjODBlYjNiNDc1Mzg2ODIxNDk4MTAzMzYyNzlmNjI1NQ%3D%3D&v=1&f=sd", + "is_gif": False, + "scrubber_media_url": "https://v.redd.it/asj4p03rdsa51/DASH_96.mp4", + "transcoding_status": "completed", + "width": 640, + } + }, + "secure_media_embed": {}, + "selftext": "", + "selftext_html": None, + "send_replies": True, + "spoiler": False, + "stickied": False, + "subreddit": "aww", + "subreddit_id": "t5_2qh1o", + "subreddit_name_prefixed": "r/aww", + "subreddit_subscribers": 25634399, + "subreddit_type": "public", + "suggested_sort": None, + "thumbnail": "https://b.thumbs.redditmedia.com/l_4Yk7NC8hz2HM0D3Hv2dK_nZBjpL8FL3NPv9WkRo8k.jpg", + "thumbnail_height": 78, + "thumbnail_width": 140, + "title": "Bunnies flop over when they feel " + "completely safe beside their " + "protectors", + "top_awarded_type": None, + "total_awards_received": 12, + "treatment_tags": [], + "ups": 112661, + "upvote_ratio": 0.94, + "url": "https://v.redd.it/asj4p03rdsa51", + "url_overridden_by_dest": "https://v.redd.it/asj4p03rdsa51", + "user_reports": [], + "view_count": None, + "visited": False, + "whitelist_status": "all_ads", + "wls": 6, + }, + "kind": "t3", + }, + ], + "dist": 25, + "modhash": None, + }, + "kind": "Listing", +} + +external_video_mock = { + "data": { + "after": "t3_hr3mhe", + "before": None, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "aww", + "selftext": "", + "author_fullname": "t2_ot2b2", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Dog splashing in water", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/aww", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "thumbnail_height": 140, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hulh8k", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.94, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 1142, + "total_awards_received": 0, + "media_embed": { + "content": '<iframe class="embedly-embed" src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fgfycat.com%2Fifr%2Fexcellentinfantileamericanwigeon&display_name=Gfycat&url=https%3A%2F%2Fgfycat.com%2Fexcellentinfantileamericanwigeon&image=https%3A%2F%2Fthumbs.gfycat.com%2FExcellentInfantileAmericanwigeon-size_restricted.gif&key=ed8fa8699ce04833838e66ce79ba05f1&type=text%2Fhtml&schema=gfycat" width="400" height="400" scrolling="no" title="Gfycat embed" frameborder="0" allow="autoplay; fullscreen" allowfullscreen="True"></iframe>', + "width": 400, + "scrolling": False, + "height": 400, + }, + "thumbnail_width": 140, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": { + "oembed": { + "provider_url": "https://gfycat.com", + "description": 'Hi! We use cookies and similar technologies ("cookies"), including third-party cookies, on this website to help operate and improve your experience on our site, monitor our site performance, and for advertising purposes. By clicking "Accept Cookies" below, you are giving us consent to use cookies (except consent is not required for cookies necessary to run our site).', + "title": "97991217 286625482366728 7551185146460766208 n", + "author_name": "Gfycat", + "height": 400, + "width": 400, + "html": '<iframe class="embedly-embed" src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fgfycat.com%2Fifr%2Fexcellentinfantileamericanwigeon&display_name=Gfycat&url=https%3A%2F%2Fgfycat.com%2Fexcellentinfantileamericanwigeon&image=https%3A%2F%2Fthumbs.gfycat.com%2FExcellentInfantileAmericanwigeon-size_restricted.gif&key=ed8fa8699ce04833838e66ce79ba05f1&type=text%2Fhtml&schema=gfycat" width="400" height="400" scrolling="no" title="Gfycat embed" frameborder="0" allow="autoplay; fullscreen" allowfullscreen="True"></iframe>', + "thumbnail_width": 250, + "version": "1.0", + "provider_name": "Gfycat", + "thumbnail_url": "https://thumbs.gfycat.com/ExcellentInfantileAmericanwigeon-size_restricted.gif", + "type": "video", + "thumbnail_height": 250, + }, + "type": "gfycat.com", + }, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": { + "content": '<iframe class="embedly-embed" src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fgfycat.com%2Fifr%2Fexcellentinfantileamericanwigeon&display_name=Gfycat&url=https%3A%2F%2Fgfycat.com%2Fexcellentinfantileamericanwigeon&image=https%3A%2F%2Fthumbs.gfycat.com%2FExcellentInfantileAmericanwigeon-size_restricted.gif&key=ed8fa8699ce04833838e66ce79ba05f1&type=text%2Fhtml&schema=gfycat" width="400" height="400" scrolling="no" title="Gfycat embed" frameborder="0" allow="autoplay; fullscreen" allowfullscreen="True"></iframe>', + "width": 400, + "scrolling": False, + "media_domain_url": "https://www.redditmedia.com/mediaembed/hulh8k", + "height": 400, + }, + "link_flair_text": None, + "can_mod_post": False, + "score": 1142, + "approved_by": None, + "author_premium": False, + "thumbnail": "https://b.thumbs.redditmedia.com/eR_Cu4w1l9PwaM14RTEpnKD20EaK5mMxUbyK8BBDo_M.jpg", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "post_hint": "rich:video", + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [], + "created": 1595281442, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "gfycat.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://gfycat.com/excellentinfantileamericanwigeon", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "preview": { + "images": [ + { + "source": { + "url": "https://external-preview.redd.it/rZXN_aGbww8NNlhGWB-5cjHPonSST4S7aS6uaZyb_W4.jpg?auto=webp&s=2a2d3a1e0a06742bf752c1c4e1582c2fa49793a3", + "width": 250, + "height": 250, + }, + "resolutions": [ + { + "url": "https://external-preview.redd.it/rZXN_aGbww8NNlhGWB-5cjHPonSST4S7aS6uaZyb_W4.jpg?width=108&crop=smart&auto=webp&s=35f61b003416516f664682717876a94d186793ae", + "width": 108, + "height": 108, + }, + { + "url": "https://external-preview.redd.it/rZXN_aGbww8NNlhGWB-5cjHPonSST4S7aS6uaZyb_W4.jpg?width=216&crop=smart&auto=webp&s=842416c1b8f8fae758a7ba6eb98af93ee2404a8d", + "width": 216, + "height": 216, + }, + ], + "variants": {}, + "id": "IVorc9dV9K9nJhhSVFKST92dfGfmhgBQjw257DWmJcE", + } + ], + "reddit_video_preview": { + "fallback_url": "https://v.redd.it/syp9pkiu00c51/DASH_360.mp4", + "height": 400, + "width": 400, + "scrubber_media_url": "https://v.redd.it/syp9pkiu00c51/DASH_96.mp4", + "dash_url": "https://v.redd.it/syp9pkiu00c51/DASHPlaylist.mpd", + "duration": 21, + "hls_url": "https://v.redd.it/syp9pkiu00c51/HLSPlaylist.m3u8", + "is_gif": True, + "transcoding_status": "completed", + }, + "enabled": False, + }, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1o", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hulh8k", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheRikari", + "discussion_type": None, + "num_comments": 21, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_hujqxu", + "author_flair_text_color": None, + "permalink": "/r/aww/comments/hulh8k/dog_splashing_in_water/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://gfycat.com/excellentinfantileamericanwigeon", + "subreddit_subscribers": 25721914, + "created_utc": 1595252642, + "num_crossposts": 0, + "media": { + "oembed": { + "provider_url": "https://gfycat.com", + "description": 'Hi! We use cookies and similar technologies ("cookies"), including third-party cookies, on this website to help operate and improve your experience on our site, monitor our site performance, and for advertising purposes. By clicking "Accept Cookies" below, you are giving us consent to use cookies (except consent is not required for cookies necessary to run our site).', + "title": "97991217 286625482366728 7551185146460766208 n", + "author_name": "Gfycat", + "height": 400, + "width": 400, + "html": '<iframe class="embedly-embed" src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fgfycat.com%2Fifr%2Fexcellentinfantileamericanwigeon&display_name=Gfycat&url=https%3A%2F%2Fgfycat.com%2Fexcellentinfantileamericanwigeon&image=https%3A%2F%2Fthumbs.gfycat.com%2FExcellentInfantileAmericanwigeon-size_restricted.gif&key=ed8fa8699ce04833838e66ce79ba05f1&type=text%2Fhtml&schema=gfycat" width="400" height="400" scrolling="no" title="Gfycat embed" frameborder="0" allow="autoplay; fullscreen" allowfullscreen="True"></iframe>', + "thumbnail_width": 250, + "version": "1.0", + "provider_name": "Gfycat", + "thumbnail_url": "https://thumbs.gfycat.com/ExcellentInfantileAmericanwigeon-size_restricted.gif", + "type": "video", + "thumbnail_height": 250, + }, + "type": "gfycat.com", + }, + "is_video": False, + }, + } + ], + "dist": 25, + "modhash": None, + }, + "kind": "Listing", +} + +external_gifv_mock = { + "data": { + "after": "t3_hr3mhe", + "before": None, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "aww", + "selftext": "", + "author_fullname": "t2_ygx0p1u", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "if i fits i sits", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/aww", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "thumbnail_height": 74, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_humdlf", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.97, + "author_flair_background_color": "", + "subreddit_type": "public", + "ups": 7512, + "total_awards_received": 1, + "media_embed": {}, + "thumbnail_width": 140, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 7512, + "approved_by": None, + "author_premium": True, + "thumbnail": "https://b.thumbs.redditmedia.com/QHK44nUFZup-hfFX2Z1dXhk-1lPEmROUCB3bBujvTck.jpg", + "edited": False, + "author_flair_css_class": "k", + "author_flair_richtext": [], + "gildings": {}, + "post_hint": "link", + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1595284712, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.imgur.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": "confidence", + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.imgur.com/grVh2AG.gifv", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": False, + "pinned": False, + "over_18": False, + "preview": { + "images": [ + { + "source": { + "url": "https://external-preview.redd.it/XDHMFAMlcn3iuSqmWeDIc4yG-q-6xnTIdGivYX-cus4.jpg?auto=webp&s=c4ba246318b3502b080d37fcbdb12e07221401a9", + "width": 638, + "height": 338, + }, + "resolutions": [ + { + "url": "https://external-preview.redd.it/XDHMFAMlcn3iuSqmWeDIc4yG-q-6xnTIdGivYX-cus4.jpg?width=108&crop=smart&auto=webp&s=c9c340a60ba3da1af3f5d5c08f3ed618ebd567d4", + "width": 108, + "height": 57, + }, + { + "url": "https://external-preview.redd.it/XDHMFAMlcn3iuSqmWeDIc4yG-q-6xnTIdGivYX-cus4.jpg?width=216&crop=smart&auto=webp&s=d05c0415e3dc63d097264bfb1b35b09676bd24f6", + "width": 216, + "height": 114, + }, + { + "url": "https://external-preview.redd.it/XDHMFAMlcn3iuSqmWeDIc4yG-q-6xnTIdGivYX-cus4.jpg?width=320&crop=smart&auto=webp&s=5c236179ccfff29e9ba980f31d5a6a9905adbe86", + "width": 320, + "height": 169, + }, + ], + "variants": {}, + "id": "4Z8zF5e4sZJnX4vWH7pZkbqiDPMCuh2J4kNotV9AGSI", + } + ], + "reddit_video_preview": { + "fallback_url": "https://v.redd.it/zzctc8y2dzb51/DASH_240.mp4", + "height": 338, + "width": 638, + "scrubber_media_url": "https://v.redd.it/zzctc8y2dzb51/DASH_96.mp4", + "dash_url": "https://v.redd.it/zzctc8y2dzb51/DASHPlaylist.mpd", + "duration": 44, + "hls_url": "https://v.redd.it/zzctc8y2dzb51/HLSPlaylist.m3u8", + "is_gif": True, + "transcoding_status": "completed", + }, + "enabled": False, + }, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": False, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1o", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "humdlf", + "is_robot_indexable": True, + "report_reasons": None, + "author": "jasontaken", + "discussion_type": None, + "num_comments": 67, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/aww/comments/humdlf/if_i_fits_i_sits/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.imgur.com/grVh2AG.gifv", + "subreddit_subscribers": 25723833, + "created_utc": 1595255912, + "num_crossposts": 1, + "media": None, + "is_video": False, + }, + } + ], + "dist": 25, + "modhash": None, + }, + "kind": "Listing", +} + +unknown_mock = { + "data": { + "after": "t3_hr3mhe", + "before": None, + "children": [ + { + "kind": "t1", + "data": { + "approved_at_utc": None, + "subreddit": "aww", + "selftext": "", + "author_fullname": "t2_ygx0p1u", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "if i fits i sits", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/aww", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "thumbnail_height": 74, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_humdlf", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.97, + "author_flair_background_color": "", + "subreddit_type": "public", + "ups": 7512, + "total_awards_received": 1, + "media_embed": {}, + "thumbnail_width": 140, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 7512, + "approved_by": None, + "author_premium": True, + "thumbnail": "https://b.thumbs.redditmedia.com/QHK44nUFZup-hfFX2Z1dXhk-1lPEmROUCB3bBujvTck.jpg", + "edited": False, + "author_flair_css_class": "k", + "author_flair_richtext": [], + "gildings": {}, + "post_hint": "link", + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1595284712, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.imgur.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": "confidence", + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.imgur.com/grVh2AG.gifv", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": False, + "pinned": False, + "over_18": False, + "preview": { + "images": [ + { + "source": { + "url": "https://external-preview.redd.it/XDHMFAMlcn3iuSqmWeDIc4yG-q-6xnTIdGivYX-cus4.jpg?auto=webp&s=c4ba246318b3502b080d37fcbdb12e07221401a9", + "width": 638, + "height": 338, + }, + "resolutions": [ + { + "url": "https://external-preview.redd.it/XDHMFAMlcn3iuSqmWeDIc4yG-q-6xnTIdGivYX-cus4.jpg?width=108&crop=smart&auto=webp&s=c9c340a60ba3da1af3f5d5c08f3ed618ebd567d4", + "width": 108, + "height": 57, + }, + { + "url": "https://external-preview.redd.it/XDHMFAMlcn3iuSqmWeDIc4yG-q-6xnTIdGivYX-cus4.jpg?width=216&crop=smart&auto=webp&s=d05c0415e3dc63d097264bfb1b35b09676bd24f6", + "width": 216, + "height": 114, + }, + { + "url": "https://external-preview.redd.it/XDHMFAMlcn3iuSqmWeDIc4yG-q-6xnTIdGivYX-cus4.jpg?width=320&crop=smart&auto=webp&s=5c236179ccfff29e9ba980f31d5a6a9905adbe86", + "width": 320, + "height": 169, + }, + ], + "variants": {}, + "id": "4Z8zF5e4sZJnX4vWH7pZkbqiDPMCuh2J4kNotV9AGSI", + } + ], + "reddit_video_preview": { + "fallback_url": "https://v.redd.it/zzctc8y2dzb51/DASH_240.mp4", + "height": 338, + "width": 638, + "scrubber_media_url": "https://v.redd.it/zzctc8y2dzb51/DASH_96.mp4", + "dash_url": "https://v.redd.it/zzctc8y2dzb51/DASHPlaylist.mpd", + "duration": 44, + "hls_url": "https://v.redd.it/zzctc8y2dzb51/HLSPlaylist.m3u8", + "is_gif": True, + "transcoding_status": "completed", + }, + "enabled": False, + }, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": False, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1o", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "humdlf", + "is_robot_indexable": True, + "report_reasons": None, + "author": "jasontaken", + "discussion_type": None, + "num_comments": 67, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/aww/comments/humdlf/if_i_fits_i_sits/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.imgur.com/grVh2AG.gifv", + "subreddit_subscribers": 25723833, + "created_utc": 1595255912, + "num_crossposts": 1, + "media": None, + "is_video": False, + }, + } + ], + "dist": 25, + "modhash": None, + }, + "kind": "Listing", +} diff --git a/src/newsreader/news/collection/tests/reddit/builder/tests.py b/src/newsreader/news/collection/tests/reddit/builder/tests.py index eb8182a..0df0d37 100644 --- a/src/newsreader/news/collection/tests/reddit/builder/tests.py +++ b/src/newsreader/news/collection/tests/reddit/builder/tests.py @@ -86,7 +86,7 @@ class RedditBuilderTestCase(TestCase): def test_update_posts(self): subreddit = SubredditFactory() existing_post = RedditPostFactory( - remote_identifier="hngsj8", + remote_identifier="hm0qct", author="Old author", title="Old title", body="Old body", @@ -108,17 +108,24 @@ class RedditBuilderTestCase(TestCase): existing_post.refresh_from_db() - self.assertEquals(existing_post.remote_identifier, "hngsj8") - self.assertEquals(existing_post.author, "nixcraft") - self.assertEquals(existing_post.title, "KeePassXC 2.6.0 released") - self.assertEquals(existing_post.body, "") + self.assertEquals(existing_post.remote_identifier, "hm0qct") + self.assertEquals(existing_post.author, "AutoModerator") + self.assertEquals( + existing_post.title, + "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", + ) + self.assertIn( + "This megathread is also to hear opinions from anyone just starting out " + "with Linux or those that have used Linux (GNU or otherwise) for a long time.", + existing_post.body, + ) self.assertEquals( existing_post.publication_date, - pytz.utc.localize(datetime(2020, 7, 8, 15, 11, 6)), + pytz.utc.localize(datetime(2020, 7, 6, 6, 11, 22)), ) self.assertEquals( existing_post.url, - "https://www.reddit.com/r/linux/comments/hngsj8/" "keepassxc_260_released/", + "https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", ) def test_html_sanitizing(self): @@ -219,3 +226,185 @@ class RedditBuilderTestCase(TestCase): duplicate_post.title, "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", ) + + def test_image_post(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((image_mock, mock_stream)) as builder: + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual(("hr64xh", "hr4bxo", "hr14y5", "hr2fv0"), posts.keys()) + + post = posts["hr64xh"] + + title = ( + "Ya’ll, I just can’t... this is my " + "son, Judah. My wife and I have no " + "idea how we created such a " + "beautiful child." + ) + url = "https://i.redd.it/cm2qybia1va51.jpg" + + self.assertEquals( + "https://www.reddit.com/r/aww/comments/hr64xh/yall_i_just_cant_this_is_my_son_judah_my_wife_and/", + post.url, + ) + self.assertEquals( + f'
      {title}
      ', post.body + ) + + def test_external_image_post(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((external_image_mock, mock_stream)) as builder: + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual(("hr41am", "huoldn"), posts.keys()) + + post = posts["hr41am"] + + url = "http://gfycat.com/thatalivedogwoodclubgall" + title = "Excited cows have a new brush!" + + self.assertEquals( + f'', + post.body, + ) + self.assertEquals( + "https://www.reddit.com/r/aww/comments/hr41am/excited_cows_have_a_new_brush/", + post.url, + ) + + post = posts["huoldn"] + + url = "https://i.imgur.com/usfMVUJ.jpg" + title = "Novosibirsk Zoo welcomes 16 cobalt-eyed Pallas’s cat kittens" + + self.assertEquals( + f'
      {title}
      ', post.body + ) + self.assertEquals( + "https://www.reddit.com/r/aww/comments/huoldn/novosibirsk_zoo_welcomes_16_cobalteyed_pallass/", + post.url, + ) + + def test_video_post(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((video_mock, mock_stream)) as builder: + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual(("hr32jf", "hr1r00", "hqy0ny", "hr0uzh"), posts.keys()) + + post = posts["hr1r00"] + + url = "https://v.redd.it/eyvbxaeqtta51/DASH_480.mp4?source=fallback" + + self.assertEquals( + post.url, + "https://www.reddit.com/r/aww/comments/hr1r00/cool_catt_and_his_clingy_girlfriend/", + ) + self.assertEquals( + f'
      ', + post.body, + ) + + def test_external_video_post(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((external_video_mock, mock_stream)) as builder: + builder.save() + + post = Post.objects.get() + + self.assertEquals(post.remote_identifier, "hulh8k") + + self.assertEquals( + post.url, + "https://www.reddit.com/r/aww/comments/hulh8k/dog_splashing_in_water/", + ) + + title = "Dog splashing in water" + url = "https://gfycat.com/excellentinfantileamericanwigeon" + + self.assertEquals( + f'', + post.body, + ) + + def test_external_gifv_video_post(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((external_gifv_mock, mock_stream)) as builder: + builder.save() + + post = Post.objects.get() + + self.assertEquals(post.remote_identifier, "humdlf") + + self.assertEquals( + post.url, "https://www.reddit.com/r/aww/comments/humdlf/if_i_fits_i_sits/" + ) + + url = "https://i.imgur.com/grVh2AG.mp4" + + self.assertEquals( + f'
      ', + post.body, + ) + + def test_link_only_post(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((simple_mock, mock_stream)) as builder: + builder.save() + + post = Post.objects.get(remote_identifier="hngsj8") + + title = "KeePassXC 2.6.0 released" + url = "https://keepassxc.org/blog/2020-07-07-2.6.0-released/" + + self.assertIn( + f'', + post.body, + ) + + self.assertEquals( + post.url, + "https://www.reddit.com/r/linux/comments/hngsj8/keepassxc_260_released/", + ) + + def test_skip_not_known_post_type(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((unknown_mock, mock_stream)) as builder: + builder.save() + + self.assertEquals(Post.objects.count(), 0) diff --git a/src/newsreader/scss/components/post/_post.scss b/src/newsreader/scss/components/post/_post.scss index 46d389d..6b41844 100644 --- a/src/newsreader/scss/components/post/_post.scss +++ b/src/newsreader/scss/components/post/_post.scss @@ -68,14 +68,9 @@ margin: 20px 0 5px 0; } - & img { - padding: 10px 10px 30px 10px; - - max-width: 70%; - width: inherit; - height: 100%; - - align-self: center; + & img, video { + padding: 10px 0; + max-width: 100%; } } From 863e8671dac2f96c1370232abc81af08679ebac4 Mon Sep 17 00:00:00 2001 From: sonny Date: Wed, 22 Jul 2020 22:50:17 +0200 Subject: [PATCH 127/422] Fix reddit post urls --- src/newsreader/js/pages/homepage/App.js | 4 +- .../{feedlist => postlist}/PostItem.js | 13 +-- .../FeedList.js => postlist/PostList.js} | 4 +- .../{feedlist => postlist}/filters.js | 0 src/newsreader/js/pages/homepage/constants.js | 3 + src/newsreader/news/collection/admin.py | 12 ++- src/newsreader/news/collection/reddit.py | 29 ++++-- src/newsreader/news/collection/serializers.py | 2 +- .../tests/endpoints/rule/detail/tests.py | 51 +++++------ .../tests/endpoints/rule/list/tests.py | 70 +++++++-------- .../collection/tests/feed/builder/tests.py | 6 +- .../collection/tests/feed/collector/tests.py | 14 +-- .../tests/feed/duplicate_handler/tests.py | 40 ++++----- .../collection/tests/reddit/builder/tests.py | 16 ++-- .../tests/endpoints/category/detail/tests.py | 24 ++--- .../tests/endpoints/category/list/tests.py | 76 ++++++++-------- .../core/tests/endpoints/post/detail/tests.py | 88 +++++++------------ .../core/tests/endpoints/post/list/tests.py | 66 ++++++-------- src/newsreader/news/core/tests/factories.py | 5 +- 19 files changed, 257 insertions(+), 266 deletions(-) rename src/newsreader/js/pages/homepage/components/{feedlist => postlist}/PostItem.js (83%) rename src/newsreader/js/pages/homepage/components/{feedlist/FeedList.js => postlist/PostList.js} (95%) rename src/newsreader/js/pages/homepage/components/{feedlist => postlist}/filters.js (100%) diff --git a/src/newsreader/js/pages/homepage/App.js b/src/newsreader/js/pages/homepage/App.js index bdf0149..91cfa4e 100644 --- a/src/newsreader/js/pages/homepage/App.js +++ b/src/newsreader/js/pages/homepage/App.js @@ -6,7 +6,7 @@ import { isEqual } from 'lodash'; import { fetchCategories } from './actions/categories'; import Sidebar from './components/sidebar/Sidebar.js'; -import FeedList from './components/feedlist/FeedList.js'; +import PostList from './components/postlist/PostList.js'; import PostModal from './components/PostModal.js'; import Messages from '../../components/Messages.js'; @@ -19,7 +19,7 @@ class App extends React.Component { return ( <> - + {this.props.error && ( diff --git a/src/newsreader/js/pages/homepage/components/feedlist/PostItem.js b/src/newsreader/js/pages/homepage/components/postlist/PostItem.js similarity index 83% rename from src/newsreader/js/pages/homepage/components/feedlist/PostItem.js rename to src/newsreader/js/pages/homepage/components/postlist/PostItem.js index a796916..9b64289 100644 --- a/src/newsreader/js/pages/homepage/components/feedlist/PostItem.js +++ b/src/newsreader/js/pages/homepage/components/postlist/PostItem.js @@ -1,7 +1,7 @@ import React from 'react'; import { connect } from 'react-redux'; -import { CATEGORY_TYPE, RULE_TYPE } from '../../constants.js'; +import { CATEGORY_TYPE, RULE_TYPE, FEED, SUBREDDIT } from '../../constants.js'; import { selectPost } from '../../actions/posts.js'; import { formatDatetime } from '../../../../utils.js'; @@ -14,6 +14,11 @@ class PostItem extends React.Component { ? 'posts__header posts__header--read' : 'posts__header'; + const ruleUrl = + rule.type === FEED + ? `/collection/rules/${rule.id}/` + : `/collection/rules/subreddits/${rule.id}/`; + return (
    • {this.props.selected.type == CATEGORY_TYPE && ( - + {rule.name} diff --git a/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js b/src/newsreader/js/pages/homepage/components/postlist/PostList.js similarity index 95% rename from src/newsreader/js/pages/homepage/components/feedlist/FeedList.js rename to src/newsreader/js/pages/homepage/components/postlist/PostList.js index e679eed..cd57d6d 100644 --- a/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js +++ b/src/newsreader/js/pages/homepage/components/postlist/PostList.js @@ -8,7 +8,7 @@ import { filterPosts } from './filters.js'; import LoadingIndicator from '../../../../components/LoadingIndicator.js'; import PostItem from './PostItem.js'; -class FeedList extends React.Component { +class PostList extends React.Component { checkScrollHeight = ::this.checkScrollHeight; componentDidMount() { @@ -83,4 +83,4 @@ const mapDispatchToProps = dispatch => ({ fetchPostsBySection: (rule, page = false) => dispatch(fetchPostsBySection(rule, page)), }); -export default connect(mapStateToProps, mapDispatchToProps)(FeedList); +export default connect(mapStateToProps, mapDispatchToProps)(PostList); diff --git a/src/newsreader/js/pages/homepage/components/feedlist/filters.js b/src/newsreader/js/pages/homepage/components/postlist/filters.js similarity index 100% rename from src/newsreader/js/pages/homepage/components/feedlist/filters.js rename to src/newsreader/js/pages/homepage/components/postlist/filters.js diff --git a/src/newsreader/js/pages/homepage/constants.js b/src/newsreader/js/pages/homepage/constants.js index 0e3f3d3..66b6365 100644 --- a/src/newsreader/js/pages/homepage/constants.js +++ b/src/newsreader/js/pages/homepage/constants.js @@ -1,2 +1,5 @@ export const RULE_TYPE = 'RULE'; export const CATEGORY_TYPE = 'CATEGORY'; + +export const SUBREDDIT = 'subreddit'; +export const FEED = 'feed'; diff --git a/src/newsreader/news/collection/admin.py b/src/newsreader/news/collection/admin.py index e82dea5..c5a7c5c 100644 --- a/src/newsreader/news/collection/admin.py +++ b/src/newsreader/news/collection/admin.py @@ -6,7 +6,14 @@ from newsreader.news.collection.models import CollectionRule class CollectionRuleAdmin(admin.ModelAdmin): fields = ("url", "name", "timezone", "category", "favicon", "user") - list_display = ("name", "category", "url", "last_suceeded", "succeeded") + list_display = ( + "name", + "type_display", + "category", + "url", + "last_suceeded", + "succeeded", + ) list_filter = ("user",) def save_model(self, request, obj, form, change): @@ -14,5 +21,8 @@ class CollectionRuleAdmin(admin.ModelAdmin): obj.user = request.user obj.save() + def type_display(self, rule): + return rule.get_type_display() + admin.site.register(CollectionRule, CollectionRuleAdmin) diff --git a/src/newsreader/news/collection/reddit.py b/src/newsreader/news/collection/reddit.py index 5b54762..9081a29 100644 --- a/src/newsreader/news/collection/reddit.py +++ b/src/newsreader/news/collection/reddit.py @@ -10,6 +10,7 @@ from uuid import uuid4 from django.conf import settings from django.core.cache import cache from django.utils import timezone +from django.utils.html import format_html import bleach import pytz @@ -150,11 +151,18 @@ class RedditBuilder(Builder): else "" ) elif direct_url.endswith(REDDIT_IMAGE_EXTENSIONS): - body = f'
      {title}
      ' + body = format_html( + "
      {title}
      ", + url=direct_url, + title=title, + ) elif data["is_video"]: video_info = data["secure_media"]["reddit_video"] - body = f'
      ' + body = format_html( + "
      ", + url=video_info["fallback_url"], + ) elif direct_url.endswith(REDDIT_VIDEO_EXTENSIONS): extension = next( extension.replace(".", "") @@ -163,11 +171,22 @@ class RedditBuilder(Builder): ) if extension == "gifv": - body = f'
      ' + body = format_html( + "
      ", + url=direct_url.replace(extension, "mp4"), + ) else: - body = f'
      ' + body = format_html( + "
      ", + url=direct_url, + extension=extension, + ) else: - body = f'' + body = format_html( + "", + url=direct_url, + title=title, + ) try: parsed_date = datetime.fromtimestamp(post["data"]["created_utc"]) diff --git a/src/newsreader/news/collection/serializers.py b/src/newsreader/news/collection/serializers.py index 640d16e..04bdba5 100644 --- a/src/newsreader/news/collection/serializers.py +++ b/src/newsreader/news/collection/serializers.py @@ -12,4 +12,4 @@ class RuleSerializer(serializers.ModelSerializer): class Meta: model = CollectionRule - fields = ("id", "name", "url", "favicon", "category", "user", "unread") + fields = ("id", "type", "name", "url", "favicon", "category", "user", "unread") diff --git a/src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py b/src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py index 02f7334..8dfe6ed 100644 --- a/src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py +++ b/src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py @@ -4,9 +4,9 @@ from django.test import TestCase from django.urls import reverse from newsreader.accounts.tests.factories import UserFactory -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory from newsreader.news.core.models import Post -from newsreader.news.core.tests.factories import CategoryFactory, PostFactory +from newsreader.news.core.tests.factories import CategoryFactory, FeedPostFactory class CollectionRuleDetailViewTestCase(TestCase): @@ -15,7 +15,7 @@ class CollectionRuleDetailViewTestCase(TestCase): self.client.force_login(self.user) def test_simple(self): - rule = CollectionRuleFactory(user=self.user) + rule = FeedFactory(user=self.user) response = self.client.get( reverse("api:news:collection:rules-detail", args=[rule.pk]) @@ -29,6 +29,7 @@ class CollectionRuleDetailViewTestCase(TestCase): self.assertTrue("url" in data) self.assertTrue("favicon" in data) self.assertTrue("category" in data) + self.assertTrue("type" in data) def test_not_known(self): response = self.client.get( @@ -40,7 +41,7 @@ class CollectionRuleDetailViewTestCase(TestCase): self.assertEquals(data["detail"], "Not found.") def test_post(self): - rule = CollectionRuleFactory(user=self.user) + rule = FeedFactory(user=self.user) response = self.client.post( reverse("api:news:collection:rules-detail", args=[rule.pk]) @@ -51,7 +52,7 @@ class CollectionRuleDetailViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "POST" not allowed.') def test_patch(self): - rule = CollectionRuleFactory(name="BBC", user=self.user) + rule = FeedFactory(name="BBC", user=self.user) response = self.client.patch( reverse("api:news:collection:rules-detail", args=[rule.pk]), @@ -67,7 +68,7 @@ class CollectionRuleDetailViewTestCase(TestCase): old_category = CategoryFactory(user=self.user) new_category = CategoryFactory(user=self.user) - rule = CollectionRuleFactory(name="BBC", category=old_category, user=self.user) + rule = FeedFactory(name="BBC", category=old_category, user=self.user) response = self.client.patch( reverse("api:news:collection:rules-detail", args=[rule.pk]), @@ -80,7 +81,7 @@ class CollectionRuleDetailViewTestCase(TestCase): self.assertEquals(data["category"], new_category.pk) def test_identifier_cannot_be_changed(self): - rule = CollectionRuleFactory(user=self.user) + rule = FeedFactory(user=self.user) response = self.client.patch( reverse("api:news:collection:rules-detail", args=[rule.pk]), @@ -93,7 +94,7 @@ class CollectionRuleDetailViewTestCase(TestCase): self.assertEquals(data["id"], rule.pk) def test_category_change(self): - rule = CollectionRuleFactory(user=self.user) + rule = FeedFactory(user=self.user) category = CategoryFactory(user=self.user) response = self.client.patch( @@ -108,7 +109,7 @@ class CollectionRuleDetailViewTestCase(TestCase): self.assertEquals(data["category"], category.pk) def test_put(self): - rule = CollectionRuleFactory(name="BBC", user=self.user) + rule = FeedFactory(name="BBC", user=self.user) response = self.client.put( reverse("api:news:collection:rules-detail", args=[rule.pk]), @@ -121,7 +122,7 @@ class CollectionRuleDetailViewTestCase(TestCase): self.assertEquals(data["name"], "BBC") def test_delete(self): - rule = CollectionRuleFactory(user=self.user) + rule = FeedFactory(user=self.user) response = self.client.delete( reverse("api:news:collection:rules-detail", args=[rule.pk]) @@ -132,7 +133,7 @@ class CollectionRuleDetailViewTestCase(TestCase): def test_rule_with_unauthenticated_user(self): self.client.logout() - rule = CollectionRuleFactory(name="BBC", user=self.user) + rule = FeedFactory(name="BBC", user=self.user) response = self.client.patch( reverse("api:news:collection:rules-detail", args=[rule.pk]), @@ -144,7 +145,7 @@ class CollectionRuleDetailViewTestCase(TestCase): def test_rule_with_unauthorized_user(self): other_user = UserFactory() - rule = CollectionRuleFactory(name="BBC", user=other_user) + rule = FeedFactory(name="BBC", user=other_user) response = self.client.patch( reverse("api:news:collection:rules-detail", args=[rule.pk]), @@ -155,10 +156,10 @@ class CollectionRuleDetailViewTestCase(TestCase): self.assertEquals(response.status_code, 403) def test_read_count(self): - rule = CollectionRuleFactory(user=self.user) + rule = FeedFactory(user=self.user) - PostFactory.create_batch(size=20, read=False, rule=rule) - PostFactory.create_batch(size=20, read=True, rule=rule) + FeedPostFactory.create_batch(size=20, read=False, rule=rule) + FeedPostFactory.create_batch(size=20, read=True, rule=rule) response = self.client.get( reverse("api:news:collection:rules-detail", args=[rule.pk]) @@ -175,9 +176,9 @@ class CollectionRuleReadTestCase(TestCase): self.client.force_login(self.user) def test_rule_read(self): - rule = CollectionRuleFactory(user=self.user) + rule = FeedFactory(user=self.user) - PostFactory.create_batch(size=20, read=False, rule=rule) + FeedPostFactory.create_batch(size=20, read=False, rule=rule) response = self.client.post( reverse("api:news:collection:rules-read", args=[rule.pk]) @@ -197,9 +198,9 @@ class CollectionRuleReadTestCase(TestCase): def test_unauthenticated_user(self): self.client.logout() - rule = CollectionRuleFactory(user=self.user) + rule = FeedFactory(user=self.user) - PostFactory.create_batch(size=20, read=False, rule=rule) + FeedPostFactory.create_batch(size=20, read=False, rule=rule) response = self.client.post( reverse("api:news:collection:rules-read", args=[rule.pk]) @@ -209,9 +210,9 @@ class CollectionRuleReadTestCase(TestCase): def test_unauthorized_user(self): other_user = UserFactory() - rule = CollectionRuleFactory(user=other_user) + rule = FeedFactory(user=other_user) - PostFactory.create_batch(size=20, read=False, rule=rule) + FeedPostFactory.create_batch(size=20, read=False, rule=rule) response = self.client.post( reverse("api:news:collection:rules-read", args=[rule.pk]) @@ -221,7 +222,7 @@ class CollectionRuleReadTestCase(TestCase): self.assertEquals(Post.objects.filter(read=False).count(), 20) def test_get(self): - rule = CollectionRuleFactory(user=self.user) + rule = FeedFactory(user=self.user) response = self.client.get( reverse("api:news:collection:rules-read", args=[rule.pk]) @@ -230,7 +231,7 @@ class CollectionRuleReadTestCase(TestCase): self.assertEquals(response.status_code, 405) def test_patch(self): - rule = CollectionRuleFactory(name="BBC", user=self.user) + rule = FeedFactory(name="BBC", user=self.user) response = self.client.patch( reverse("api:news:collection:rules-read", args=[rule.pk]), @@ -241,7 +242,7 @@ class CollectionRuleReadTestCase(TestCase): self.assertEquals(response.status_code, 405) def test_put(self): - rule = CollectionRuleFactory(name="BBC", user=self.user) + rule = FeedFactory(name="BBC", user=self.user) response = self.client.put( reverse("api:news:collection:rules-read", args=[rule.pk]), @@ -252,7 +253,7 @@ class CollectionRuleReadTestCase(TestCase): self.assertEquals(response.status_code, 405) def test_delete(self): - rule = CollectionRuleFactory(user=self.user) + rule = FeedFactory(user=self.user) response = self.client.delete( reverse("api:news:collection:rules-read", args=[rule.pk]) diff --git a/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py b/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py index 19d2029..4d1ba8f 100644 --- a/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py +++ b/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py @@ -8,8 +8,8 @@ from django.urls import reverse import pytz from newsreader.accounts.tests.factories import UserFactory -from newsreader.news.collection.tests.factories import CollectionRuleFactory -from newsreader.news.core.tests.factories import CategoryFactory, PostFactory +from newsreader.news.collection.tests.factories import FeedFactory +from newsreader.news.core.tests.factories import CategoryFactory, FeedPostFactory class RuleListViewTestCase(TestCase): @@ -18,7 +18,7 @@ class RuleListViewTestCase(TestCase): self.client.force_login(self.user) def test_simple(self): - CollectionRuleFactory.create_batch(size=3, user=self.user) + FeedFactory.create_batch(size=3, user=self.user) response = self.client.get(reverse("api:news:collection:rules-list")) data = response.json() @@ -30,19 +30,19 @@ class RuleListViewTestCase(TestCase): def test_ordering(self): rules = [ - CollectionRuleFactory( + FeedFactory( created=datetime.combine( date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc ), user=self.user, ), - CollectionRuleFactory( + FeedFactory( created=datetime.combine( date(2019, 7, 20), time(hour=18, minute=7, second=37), pytz.utc ), user=self.user, ), - CollectionRuleFactory( + FeedFactory( created=datetime.combine( date(2019, 7, 20), time(hour=16, minute=7, second=37), pytz.utc ), @@ -63,7 +63,7 @@ class RuleListViewTestCase(TestCase): self.assertEquals(data["results"][2]["id"], rules[0].pk) def test_pagination_count(self): - CollectionRuleFactory.create_batch(size=80, user=self.user) + FeedFactory.create_batch(size=80, user=self.user) response = self.client.get( reverse("api:news:collection:rules-list"), {"count": 30} @@ -124,7 +124,7 @@ class RuleListViewTestCase(TestCase): def test_rules_with_unauthenticated_user(self): self.client.logout() - CollectionRuleFactory.create_batch(size=3, user=self.user) + FeedFactory.create_batch(size=3, user=self.user) response = self.client.get(reverse("api:news:collection:rules-list")) @@ -132,7 +132,7 @@ class RuleListViewTestCase(TestCase): def test_rules_with_unauthorized_user(self): other_user = UserFactory() - CollectionRuleFactory.create_batch(size=3, user=other_user) + FeedFactory.create_batch(size=3, user=other_user) response = self.client.get(reverse("api:news:collection:rules-list")) data = response.json() @@ -149,8 +149,8 @@ class NestedRuleListViewTestCase(TestCase): self.client.force_login(self.user) def test_simple(self): - rule = CollectionRuleFactory.create(user=self.user) - PostFactory.create_batch(size=5, rule=rule) + rule = FeedFactory.create(user=self.user) + FeedPostFactory.create_batch(size=5, rule=rule) response = self.client.get( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}) @@ -164,8 +164,8 @@ class NestedRuleListViewTestCase(TestCase): self.assertEquals(data["count"], 5) def test_pagination(self): - rule = CollectionRuleFactory.create(user=self.user) - PostFactory.create_batch(size=80, rule=rule) + rule = FeedFactory.create(user=self.user) + FeedPostFactory.create_batch(size=80, rule=rule) response = self.client.get( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}), @@ -178,7 +178,7 @@ class NestedRuleListViewTestCase(TestCase): self.assertEquals(len(data["results"]), 30) def test_empty(self): - rule = CollectionRuleFactory.create(user=self.user) + rule = FeedFactory.create(user=self.user) response = self.client.get( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}) @@ -197,7 +197,7 @@ class NestedRuleListViewTestCase(TestCase): self.assertEquals(response.status_code, 404) def test_post(self): - rule = CollectionRuleFactory.create(user=self.user) + rule = FeedFactory.create(user=self.user) response = self.client.post( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}), @@ -210,7 +210,7 @@ class NestedRuleListViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "POST" not allowed.') def test_patch(self): - rule = CollectionRuleFactory.create(user=self.user) + rule = FeedFactory.create(user=self.user) response = self.client.patch( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}), @@ -223,7 +223,7 @@ class NestedRuleListViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "PATCH" not allowed.') def test_put(self): - rule = CollectionRuleFactory.create(user=self.user) + rule = FeedFactory.create(user=self.user) response = self.client.put( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}), @@ -236,7 +236,7 @@ class NestedRuleListViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "PUT" not allowed.') def test_delete(self): - rule = CollectionRuleFactory.create(user=self.user) + rule = FeedFactory.create(user=self.user) response = self.client.delete( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}), @@ -251,7 +251,7 @@ class NestedRuleListViewTestCase(TestCase): def test_rule_with_unauthenticated_user(self): self.client.logout() - rule = CollectionRuleFactory(user=self.user) + rule = FeedFactory(user=self.user) response = self.client.get( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}) @@ -261,7 +261,7 @@ class NestedRuleListViewTestCase(TestCase): def test_rule_with_unauthorized_user(self): other_user = UserFactory() - rule = CollectionRuleFactory(user=other_user) + rule = FeedFactory(user=other_user) response = self.client.get( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}) @@ -270,26 +270,24 @@ class NestedRuleListViewTestCase(TestCase): self.assertEquals(response.status_code, 403) def test_posts_ordering(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) posts = [ - PostFactory( + FeedPostFactory( title="I'm the first post", rule=rule, publication_date=datetime.combine( date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc ), ), - PostFactory( + FeedPostFactory( title="I'm the second post", rule=rule, publication_date=datetime.combine( date(2019, 7, 20), time(hour=18, minute=7, second=37), pytz.utc ), ), - PostFactory( + FeedPostFactory( title="I'm the third post", rule=rule, publication_date=datetime.combine( @@ -313,11 +311,11 @@ class NestedRuleListViewTestCase(TestCase): self.assertEquals(data["results"][2]["id"], posts[0].pk) def test_only_posts_from_rule_are_returned(self): - rule = CollectionRuleFactory.create(user=self.user) - other_rule = CollectionRuleFactory.create(user=self.user) + rule = FeedFactory.create(user=self.user) + other_rule = FeedFactory.create(user=self.user) - PostFactory.create_batch(size=5, rule=rule) - PostFactory.create_batch(size=5, rule=other_rule) + FeedPostFactory.create_batch(size=5, rule=rule) + FeedPostFactory.create_batch(size=5, rule=other_rule) response = self.client.get( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}) @@ -334,10 +332,10 @@ class NestedRuleListViewTestCase(TestCase): self.assertEquals(post["rule"], rule.pk) def test_unread_posts(self): - rule = CollectionRuleFactory.create(user=self.user) + rule = FeedFactory.create(user=self.user) - PostFactory.create_batch(size=10, rule=rule, read=False) - PostFactory.create_batch(size=10, rule=rule, read=True) + FeedPostFactory.create_batch(size=10, rule=rule, read=False) + FeedPostFactory.create_batch(size=10, rule=rule, read=True) response = self.client.get( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}), @@ -354,10 +352,10 @@ class NestedRuleListViewTestCase(TestCase): self.assertEquals(post["read"], False) def test_read_posts(self): - rule = CollectionRuleFactory.create(user=self.user) + rule = FeedFactory.create(user=self.user) - PostFactory.create_batch(size=20, rule=rule, read=False) - PostFactory.create_batch(size=10, rule=rule, read=True) + FeedPostFactory.create_batch(size=20, rule=rule, read=False) + FeedPostFactory.create_batch(size=10, rule=rule, read=True) response = self.client.get( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}), diff --git a/src/newsreader/news/collection/tests/feed/builder/tests.py b/src/newsreader/news/collection/tests/feed/builder/tests.py index 7069f96..c3e60e0 100644 --- a/src/newsreader/news/collection/tests/feed/builder/tests.py +++ b/src/newsreader/news/collection/tests/feed/builder/tests.py @@ -11,7 +11,7 @@ from freezegun import freeze_time from newsreader.news.collection.feed import FeedBuilder from newsreader.news.collection.tests.factories import FeedFactory from newsreader.news.core.models import Post -from newsreader.news.core.tests.factories import PostFactory +from newsreader.news.core.tests.factories import FeedPostFactory from .mocks import * @@ -287,11 +287,11 @@ class FeedBuilderTestCase(TestCase): rule = FeedFactory() mock_stream = MagicMock(rule=rule) - existing_first_post = PostFactory.create( + existing_first_post = FeedPostFactory.create( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", rule=rule ) - existing_second_post = PostFactory.create( + existing_second_post = FeedPostFactory.create( remote_identifier="a5479c66-8fae-11e9-8422-00163ef6bee7", rule=rule ) diff --git a/src/newsreader/news/collection/tests/feed/collector/tests.py b/src/newsreader/news/collection/tests/feed/collector/tests.py index b0fc7cf..5a1bac1 100644 --- a/src/newsreader/news/collection/tests/feed/collector/tests.py +++ b/src/newsreader/news/collection/tests/feed/collector/tests.py @@ -21,7 +21,7 @@ from newsreader.news.collection.feed import FeedCollector from newsreader.news.collection.tests.factories import FeedFactory from newsreader.news.collection.utils import build_publication_date from newsreader.news.core.models import Post -from newsreader.news.core.tests.factories import PostFactory +from newsreader.news.core.tests.factories import FeedPostFactory from .mocks import duplicate_mock, empty_mock, multiple_mock, multiple_update_mock @@ -143,7 +143,7 @@ class FeedCollectorTestCase(TestCase): struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), pytz.utc ) - first_post = PostFactory( + first_post = FeedPostFactory( url="https://www.bbc.co.uk/news/world-us-canada-48338168", title="Trump's 'genocidal taunts' will not end Iran - Zarif", body="Foreign Minister Mohammad Javad Zarif says the US " @@ -156,7 +156,7 @@ class FeedCollectorTestCase(TestCase): struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0)), pytz.utc ) - second_post = PostFactory( + second_post = FeedPostFactory( url="https://www.bbc.co.uk/news/technology-48334739", title="Huawei's Android loss: How it affects you", body="Google's move to end business ties with Huawei will " @@ -169,7 +169,7 @@ class FeedCollectorTestCase(TestCase): struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), pytz.utc ) - third_post = PostFactory( + third_post = FeedPostFactory( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", title="Birmingham head teacher threatened over LGBT lessons", body="Police are investigating the messages while an MP " @@ -194,7 +194,7 @@ class FeedCollectorTestCase(TestCase): self.mocked_parse.return_value = multiple_update_mock rule = FeedFactory() - first_post = PostFactory( + first_post = FeedPostFactory( remote_identifier="https://www.bbc.co.uk/news/world-us-canada-48338168", url="https://www.bbc.co.uk/", title="Trump", @@ -203,7 +203,7 @@ class FeedCollectorTestCase(TestCase): rule=rule, ) - second_post = PostFactory( + second_post = FeedPostFactory( remote_identifier="https://www.bbc.co.uk/news/technology-48334739", url="https://www.bbc.co.uk/", title="Huawei's Android loss: How it affects you", @@ -212,7 +212,7 @@ class FeedCollectorTestCase(TestCase): rule=rule, ) - third_post = PostFactory( + third_post = FeedPostFactory( remote_identifier="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", title="Birmingham head teacher threatened over LGBT lessons", diff --git a/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py index 18a6c6c..941de66 100644 --- a/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py +++ b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py @@ -8,7 +8,7 @@ from freezegun import freeze_time from newsreader.news.collection.feed import FeedDuplicateHandler from newsreader.news.collection.tests.factories import FeedFactory from newsreader.news.core.models import Post -from newsreader.news.core.tests.factories import PostFactory +from newsreader.news.core.tests.factories import FeedPostFactory @freeze_time("2019-10-30 12:30:00") @@ -19,17 +19,17 @@ class FeedDuplicateHandlerTestCase(TestCase): def test_duplicate_entries_with_remote_identifiers(self): rule = FeedFactory() - existing_post = PostFactory.create( + existing_post = FeedPostFactory.create( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", rule=rule ) - new_posts = PostFactory.build_batch( + new_posts = FeedPostFactory.build_batch( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", publication_date=timezone.now() - timedelta(days=7), rule=rule, size=5, ) - last_post = PostFactory.build( + last_post = FeedPostFactory.build( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", publication_date=timezone.now(), rule=rule, @@ -54,7 +54,7 @@ class FeedDuplicateHandlerTestCase(TestCase): def test_duplicate_entries_with_different_remote_identifiers(self): rule = FeedFactory() - existing_post = PostFactory( + existing_post = FeedPostFactory( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", url="https://bbc.com", title="New post", @@ -63,7 +63,7 @@ class FeedDuplicateHandlerTestCase(TestCase): rule=rule, ) - new_posts = PostFactory.build_batch( + new_posts = FeedPostFactory.build_batch( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7Q", url="https://bbc.com", title="New post", @@ -72,7 +72,7 @@ class FeedDuplicateHandlerTestCase(TestCase): rule=rule, size=5, ) - last_post = PostFactory.build( + last_post = FeedPostFactory.build( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7Q", url="https://bbc.com", title="New post", @@ -100,7 +100,7 @@ class FeedDuplicateHandlerTestCase(TestCase): def test_duplicate_entries_in_recent_database(self): rule = FeedFactory() - existing_post = PostFactory( + existing_post = FeedPostFactory( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", title="Birmingham head teacher threatened over LGBT lessons", body="Google's move to end business ties with Huawei will affect current devices", @@ -109,7 +109,7 @@ class FeedDuplicateHandlerTestCase(TestCase): rule=rule, ) - new_posts = PostFactory.build_batch( + new_posts = FeedPostFactory.build_batch( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", title="Birmingham head teacher threatened over LGBT lessons", body="Google's move to end business ties with Huawei will affect current devices", @@ -119,7 +119,7 @@ class FeedDuplicateHandlerTestCase(TestCase): size=5, ) - last_post = PostFactory.build( + last_post = FeedPostFactory.build( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", title="Birmingham head teacher threatened over LGBT lessons", body="Google's move to end business ties with Huawei will affect current devices", @@ -147,17 +147,17 @@ class FeedDuplicateHandlerTestCase(TestCase): def test_multiple_existing_entries_with_identifier(self): rule = FeedFactory() - PostFactory.create_batch( + FeedPostFactory.create_batch( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", rule=rule, size=5 ) - new_posts = PostFactory.build_batch( + new_posts = FeedPostFactory.build_batch( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", publication_date=timezone.now() - timedelta(hours=5), rule=rule, size=5, ) - last_post = PostFactory.build( + last_post = FeedPostFactory.build( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", publication_date=timezone.now() - timedelta(minutes=5), rule=rule, @@ -189,7 +189,7 @@ class FeedDuplicateHandlerTestCase(TestCase): def test_duplicate_entries_outside_time_slot(self): rule = FeedFactory() - existing_post = PostFactory( + existing_post = FeedPostFactory( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", title="Birmingham head teacher threatened over LGBT lessons", body="Google's move to end business ties with Huawei will affect current devices", @@ -198,7 +198,7 @@ class FeedDuplicateHandlerTestCase(TestCase): rule=rule, ) - new_posts = PostFactory.build_batch( + new_posts = FeedPostFactory.build_batch( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", title="Birmingham head teacher threatened over LGBT lessons", body="Google's move to end business ties with Huawei will affect current devices", @@ -207,7 +207,7 @@ class FeedDuplicateHandlerTestCase(TestCase): rule=rule, size=5, ) - last_post = PostFactory.build( + last_post = FeedPostFactory.build( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", title="Birmingham head teacher threatened over LGBT lessons", body="Google's move to end business ties with Huawei will affect current devices", @@ -235,14 +235,14 @@ class FeedDuplicateHandlerTestCase(TestCase): def test_duplicate_entries_in_collected_entries(self): rule = FeedFactory() - post_1 = PostFactory.build( + post_1 = FeedPostFactory.build( title="title got updated", body="body", url="https://bbc.com", publication_date=timezone.now(), rule=rule, ) - duplicate_post_1 = PostFactory.build( + duplicate_post_1 = FeedPostFactory.build( title="title got updated", body="body", url="https://bbc.com", @@ -250,11 +250,11 @@ class FeedDuplicateHandlerTestCase(TestCase): rule=rule, ) - post_2 = PostFactory.build( + post_2 = FeedPostFactory.build( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", publication_date=timezone.now(), ) - duplicate_post_2 = PostFactory.build( + duplicate_post_2 = FeedPostFactory.build( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", publication_date=timezone.now() - timedelta(minutes=5), ) diff --git a/src/newsreader/news/collection/tests/reddit/builder/tests.py b/src/newsreader/news/collection/tests/reddit/builder/tests.py index 0df0d37..9c1a046 100644 --- a/src/newsreader/news/collection/tests/reddit/builder/tests.py +++ b/src/newsreader/news/collection/tests/reddit/builder/tests.py @@ -255,7 +255,7 @@ class RedditBuilderTestCase(TestCase): post.url, ) self.assertEquals( - f'
      {title}
      ', post.body + f"
      {title}
      ", post.body ) def test_external_image_post(self): @@ -277,7 +277,7 @@ class RedditBuilderTestCase(TestCase): title = "Excited cows have a new brush!" self.assertEquals( - f'', + f"", post.body, ) self.assertEquals( @@ -291,7 +291,7 @@ class RedditBuilderTestCase(TestCase): title = "Novosibirsk Zoo welcomes 16 cobalt-eyed Pallas’s cat kittens" self.assertEquals( - f'
      {title}
      ', post.body + f"
      {title}
      ", post.body ) self.assertEquals( "https://www.reddit.com/r/aww/comments/huoldn/novosibirsk_zoo_welcomes_16_cobalteyed_pallass/", @@ -320,7 +320,7 @@ class RedditBuilderTestCase(TestCase): "https://www.reddit.com/r/aww/comments/hr1r00/cool_catt_and_his_clingy_girlfriend/", ) self.assertEquals( - f'
      ', + f"
      ", post.body, ) @@ -346,7 +346,7 @@ class RedditBuilderTestCase(TestCase): url = "https://gfycat.com/excellentinfantileamericanwigeon" self.assertEquals( - f'', + f"", post.body, ) @@ -367,10 +367,8 @@ class RedditBuilderTestCase(TestCase): post.url, "https://www.reddit.com/r/aww/comments/humdlf/if_i_fits_i_sits/" ) - url = "https://i.imgur.com/grVh2AG.mp4" - self.assertEquals( - f'
      ', + "
      ", post.body, ) @@ -389,7 +387,7 @@ class RedditBuilderTestCase(TestCase): url = "https://keepassxc.org/blog/2020-07-07-2.6.0-released/" self.assertIn( - f'', + f"", post.body, ) diff --git a/src/newsreader/news/core/tests/endpoints/category/detail/tests.py b/src/newsreader/news/core/tests/endpoints/category/detail/tests.py index 864a144..1f42a20 100644 --- a/src/newsreader/news/core/tests/endpoints/category/detail/tests.py +++ b/src/newsreader/news/core/tests/endpoints/category/detail/tests.py @@ -4,8 +4,8 @@ from django.test import TestCase from django.urls import reverse from newsreader.accounts.tests.factories import UserFactory -from newsreader.news.collection.tests.factories import CollectionRuleFactory -from newsreader.news.core.tests.factories import CategoryFactory, PostFactory +from newsreader.news.collection.tests.factories import FeedFactory +from newsreader.news.core.tests.factories import CategoryFactory, FeedPostFactory class CategoryDetailViewTestCase(TestCase): @@ -116,11 +116,11 @@ class CategoryDetailViewTestCase(TestCase): def test_read_count(self): category = CategoryFactory(user=self.user) - unread_rule = CollectionRuleFactory(category=category) - read_rule = CollectionRuleFactory(category=category) + unread_rule = FeedFactory(category=category) + read_rule = FeedFactory(category=category) - PostFactory.create_batch(size=20, read=False, rule=unread_rule) - PostFactory.create_batch(size=20, read=True, rule=read_rule) + FeedPostFactory.create_batch(size=20, read=False, rule=unread_rule) + FeedPostFactory.create_batch(size=20, read=True, rule=read_rule) response = self.client.get( reverse("api:news:core:categories-detail", args=[category.pk]) @@ -139,8 +139,8 @@ class CategoryReadTestCase(TestCase): def test_category_read(self): category = CategoryFactory(user=self.user) rules = [ - PostFactory.create_batch(size=5, read=False, rule=rule) - for rule in CollectionRuleFactory.create_batch(size=5, category=category) + FeedPostFactory.create_batch(size=5, read=False, rule=rule) + for rule in FeedFactory.create_batch(size=5, category=category) ] response = self.client.post( @@ -165,8 +165,8 @@ class CategoryReadTestCase(TestCase): category = CategoryFactory(user=self.user) rules = [ - PostFactory.create_batch(size=5, read=False, rule=rule) - for rule in CollectionRuleFactory.create_batch( + FeedPostFactory.create_batch(size=5, read=False, rule=rule) + for rule in FeedFactory.create_batch( size=5, category=category, user=self.user ) ] @@ -182,8 +182,8 @@ class CategoryReadTestCase(TestCase): category = CategoryFactory(user=other_user) rules = [ - PostFactory.create_batch(size=5, read=False, rule=rule) - for rule in CollectionRuleFactory.create_batch( + FeedPostFactory.create_batch(size=5, read=False, rule=rule) + for rule in FeedFactory.create_batch( size=5, category=category, user=other_user ) ] diff --git a/src/newsreader/news/core/tests/endpoints/category/list/tests.py b/src/newsreader/news/core/tests/endpoints/category/list/tests.py index 4d5f0e6..15fb166 100644 --- a/src/newsreader/news/core/tests/endpoints/category/list/tests.py +++ b/src/newsreader/news/core/tests/endpoints/category/list/tests.py @@ -8,8 +8,8 @@ from django.urls import reverse import pytz from newsreader.accounts.tests.factories import UserFactory -from newsreader.news.collection.tests.factories import CollectionRuleFactory -from newsreader.news.core.tests.factories import CategoryFactory, PostFactory +from newsreader.news.collection.tests.factories import FeedFactory +from newsreader.news.core.tests.factories import CategoryFactory, FeedPostFactory class CategoryListViewTestCase(TestCase): @@ -125,7 +125,7 @@ class NestedCategoryListViewTestCase(TestCase): def test_simple(self): category = CategoryFactory.create(user=self.user) - rules = CollectionRuleFactory.create_batch(size=5, category=category) + rules = FeedFactory.create_batch(size=5, category=category) response = self.client.get( reverse("api:news:core:categories-nested-rules", kwargs={"pk": category.pk}) @@ -219,7 +219,7 @@ class NestedCategoryListViewTestCase(TestCase): self.client.logout() category = CategoryFactory.create(user=self.user) - rules = CollectionRuleFactory.create_batch(size=5, category=category) + rules = FeedFactory.create_batch(size=5, category=category) response = self.client.get( reverse("api:news:core:categories-nested-rules", kwargs={"pk": category.pk}) @@ -231,7 +231,7 @@ class NestedCategoryListViewTestCase(TestCase): other_user = UserFactory.create() category = CategoryFactory.create(user=other_user) - rules = CollectionRuleFactory.create_batch(size=5, category=category) + rules = FeedFactory.create_batch(size=5, category=category) response = self.client.get( reverse("api:news:core:categories-nested-rules", kwargs={"pk": category.pk}) @@ -242,9 +242,9 @@ class NestedCategoryListViewTestCase(TestCase): def test_ordering(self): category = CategoryFactory.create(user=self.user) rules = [ - CollectionRuleFactory.create(category=category, name="Durp"), - CollectionRuleFactory.create(category=category, name="Slurp"), - CollectionRuleFactory.create(category=category, name="Burp"), + FeedFactory.create(category=category, name="Durp"), + FeedFactory.create(category=category, name="Slurp"), + FeedFactory.create(category=category, name="Burp"), ] response = self.client.get( @@ -261,13 +261,13 @@ class NestedCategoryListViewTestCase(TestCase): def test_only_rules_from_category_are_returned(self): other_category = CategoryFactory(user=self.user) - CollectionRuleFactory.create_batch(size=5, category=other_category) + FeedFactory.create_batch(size=5, category=other_category) category = CategoryFactory.create(user=self.user) rules = [ - CollectionRuleFactory.create(category=category, name="Durp"), - CollectionRuleFactory.create(category=category, name="Slurp"), - CollectionRuleFactory.create(category=category, name="Burp"), + FeedFactory.create(category=category, name="Durp"), + FeedFactory.create(category=category, name="Slurp"), + FeedFactory.create(category=category, name="Burp"), ] response = self.client.get( @@ -291,8 +291,8 @@ class NestedCategoryPostView(TestCase): def test_simple(self): category = CategoryFactory.create(user=self.user) rules = { - rule.pk: PostFactory.create_batch(size=5, rule=rule) - for rule in CollectionRuleFactory.create_batch( + rule.pk: FeedPostFactory.create_batch(size=5, rule=rule) + for rule in FeedFactory.create_batch( size=5, category=category, user=self.user ) } @@ -327,9 +327,7 @@ class NestedCategoryPostView(TestCase): def test_no_posts(self): category = CategoryFactory.create(user=self.user) - rules = CollectionRuleFactory.create_batch( - size=5, user=self.user, category=category - ) + rules = FeedFactory.create_batch(size=5, user=self.user, category=category) response = self.client.get( reverse("api:news:core:categories-nested-posts", kwargs={"pk": category.pk}) @@ -427,25 +425,23 @@ class NestedCategoryPostView(TestCase): def test_ordering(self): category = CategoryFactory.create(user=self.user) - bbc_rule = CollectionRuleFactory.create( - name="BBC", category=category, user=self.user - ) - guardian_rule = CollectionRuleFactory.create( + bbc_rule = FeedFactory.create(name="BBC", category=category, user=self.user) + guardian_rule = FeedFactory.create( name="The Guardian", category=category, user=self.user ) - reuters_rule = CollectionRuleFactory.create( + reuters_rule = FeedFactory.create( name="Reuters", category=category, user=self.user ) reuters_rule = [ - PostFactory.create( + FeedPostFactory.create( title="Second Reuters post", rule=reuters_rule, publication_date=datetime.combine( date(2019, 5, 21), time(hour=16, minute=7, second=37), pytz.utc ), ), - PostFactory.create( + FeedPostFactory.create( title="First Reuters post", rule=reuters_rule, publication_date=datetime.combine( @@ -455,14 +451,14 @@ class NestedCategoryPostView(TestCase): ] guardian_posts = [ - PostFactory.create( + FeedPostFactory.create( title="Second Guardian post", rule=guardian_rule, publication_date=datetime.combine( date(2019, 5, 21), time(hour=16, minute=7, second=37), pytz.utc ), ), - PostFactory.create( + FeedPostFactory.create( title="First Guardian post", rule=guardian_rule, publication_date=datetime.combine( @@ -472,14 +468,14 @@ class NestedCategoryPostView(TestCase): ] bbc_posts = [ - PostFactory.create( + FeedPostFactory.create( title="Second BBC post", rule=bbc_rule, publication_date=datetime.combine( date(2019, 5, 21), time(hour=16, minute=7, second=37), pytz.utc ), ), - PostFactory.create( + FeedPostFactory.create( title="First BBC post", rule=bbc_rule, publication_date=datetime.combine( @@ -509,19 +505,19 @@ class NestedCategoryPostView(TestCase): category = CategoryFactory.create(user=self.user) other_category = CategoryFactory.create(user=self.user) - guardian_rule = CollectionRuleFactory.create( + guardian_rule = FeedFactory.create( name="BBC", category=category, user=self.user ) - other_rule = CollectionRuleFactory.create(name="The Guardian", user=self.user) + other_rule = FeedFactory.create(name="The Guardian", user=self.user) guardian_posts = [ - PostFactory.create(rule=guardian_rule), - PostFactory.create(rule=guardian_rule), + FeedPostFactory.create(rule=guardian_rule), + FeedPostFactory.create(rule=guardian_rule), ] other_posts = [ - PostFactory.create(rule=other_rule), - PostFactory.create(rule=other_rule), + FeedPostFactory.create(rule=other_rule), + FeedPostFactory.create(rule=other_rule), ] response = self.client.get( @@ -538,10 +534,10 @@ class NestedCategoryPostView(TestCase): def test_unread_posts(self): category = CategoryFactory.create(user=self.user) - rule = CollectionRuleFactory(category=category) + rule = FeedFactory(category=category) - PostFactory.create_batch(size=10, rule=rule, read=False) - PostFactory.create_batch(size=10, rule=rule, read=True) + FeedPostFactory.create_batch(size=10, rule=rule, read=False) + FeedPostFactory.create_batch(size=10, rule=rule, read=True) response = self.client.get( reverse( @@ -561,10 +557,10 @@ class NestedCategoryPostView(TestCase): def test_read_posts(self): category = CategoryFactory.create(user=self.user) - rule = CollectionRuleFactory(category=category) + rule = FeedFactory(category=category) - PostFactory.create_batch(size=20, rule=rule, read=False) - PostFactory.create_batch(size=10, rule=rule, read=True) + FeedPostFactory.create_batch(size=20, rule=rule, read=False) + FeedPostFactory.create_batch(size=10, rule=rule, read=True) response = self.client.get( reverse( diff --git a/src/newsreader/news/core/tests/endpoints/post/detail/tests.py b/src/newsreader/news/core/tests/endpoints/post/detail/tests.py index c804ff5..2d25a89 100644 --- a/src/newsreader/news/core/tests/endpoints/post/detail/tests.py +++ b/src/newsreader/news/core/tests/endpoints/post/detail/tests.py @@ -4,8 +4,8 @@ from django.test import TestCase from django.urls import reverse from newsreader.accounts.tests.factories import UserFactory -from newsreader.news.collection.tests.factories import CollectionRuleFactory -from newsreader.news.core.tests.factories import CategoryFactory, PostFactory +from newsreader.news.collection.tests.factories import FeedFactory +from newsreader.news.core.tests.factories import CategoryFactory, FeedPostFactory class PostDetailViewTestCase(TestCase): @@ -14,10 +14,8 @@ class PostDetailViewTestCase(TestCase): self.client.force_login(self.user) def test_simple(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - post = PostFactory(rule=rule) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = FeedPostFactory(rule=rule) response = self.client.get( reverse("api:news:core:posts-detail", args=[post.pk]) @@ -43,10 +41,8 @@ class PostDetailViewTestCase(TestCase): self.assertEquals(data["detail"], "Not found.") def test_post(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - post = PostFactory(rule=rule) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = FeedPostFactory(rule=rule) response = self.client.post( reverse("api:news:core:posts-detail", args=[post.pk]) @@ -57,10 +53,8 @@ class PostDetailViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "POST" not allowed.') def test_patch(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - post = PostFactory(title="This is clickbait for sure", rule=rule) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = FeedPostFactory(title="This is clickbait for sure", rule=rule) response = self.client.patch( reverse("api:news:core:posts-detail", args=[post.pk]), @@ -73,10 +67,8 @@ class PostDetailViewTestCase(TestCase): self.assertEquals(data["title"], "This title is very accurate") def test_identifier_cannot_be_changed(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - post = PostFactory(title="This is clickbait for sure", rule=rule) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = FeedPostFactory(title="This is clickbait for sure", rule=rule) response = self.client.patch( reverse("api:news:core:posts-detail", args=[post.pk]), @@ -89,13 +81,9 @@ class PostDetailViewTestCase(TestCase): self.assertEquals(data["id"], post.pk) def test_rule_cannot_be_changed(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - new_rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - post = PostFactory(title="This is clickbait for sure", rule=rule) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + new_rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = FeedPostFactory(title="This is clickbait for sure", rule=rule) response = self.client.patch( reverse("api:news:core:posts-detail", args=[post.pk]), @@ -115,10 +103,8 @@ class PostDetailViewTestCase(TestCase): self.assertTrue(data["rule"], rule.pk) def test_put(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - post = PostFactory(title="This is clickbait for sure", rule=rule) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = FeedPostFactory(title="This is clickbait for sure", rule=rule) response = self.client.put( reverse("api:news:core:posts-detail", args=[post.pk]), @@ -131,10 +117,8 @@ class PostDetailViewTestCase(TestCase): self.assertEquals(data["title"], "This title is very accurate") def test_delete(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - post = PostFactory(rule=rule) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = FeedPostFactory(rule=rule) response = self.client.delete( reverse("api:news:core:posts-detail", args=[post.pk]) @@ -147,8 +131,8 @@ class PostDetailViewTestCase(TestCase): def test_post_with_unauthenticated_user_without_category(self): self.client.logout() - rule = CollectionRuleFactory(user=self.user, category=None) - post = PostFactory(rule=rule) + rule = FeedFactory(user=self.user, category=None) + post = FeedPostFactory(rule=rule) response = self.client.get( reverse("api:news:core:posts-detail", args=[post.pk]) @@ -159,10 +143,8 @@ class PostDetailViewTestCase(TestCase): def test_post_with_unauthenticated_user_with_category(self): self.client.logout() - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - post = PostFactory(rule=rule) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = FeedPostFactory(rule=rule) response = self.client.get( reverse("api:news:core:posts-detail", args=[post.pk]) @@ -172,8 +154,8 @@ class PostDetailViewTestCase(TestCase): def test_post_with_unauthorized_user_without_category(self): other_user = UserFactory() - rule = CollectionRuleFactory(user=other_user, category=None) - post = PostFactory(rule=rule) + rule = FeedFactory(user=other_user, category=None) + post = FeedPostFactory(rule=rule) response = self.client.get( reverse("api:news:core:posts-detail", args=[post.pk]) @@ -183,10 +165,8 @@ class PostDetailViewTestCase(TestCase): def test_post_with_unauthorized_user_with_category(self): other_user = UserFactory() - rule = CollectionRuleFactory( - user=other_user, category=CategoryFactory(user=other_user) - ) - post = PostFactory(rule=rule) + rule = FeedFactory(user=other_user, category=CategoryFactory(user=other_user)) + post = FeedPostFactory(rule=rule) response = self.client.get( reverse("api:news:core:posts-detail", args=[post.pk]) @@ -196,10 +176,8 @@ class PostDetailViewTestCase(TestCase): def test_post_with_different_user_for_category_and_rule(self): other_user = UserFactory() - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=other_user) - ) - post = PostFactory(rule=rule) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=other_user)) + post = FeedPostFactory(rule=rule) response = self.client.get( reverse("api:news:core:posts-detail", args=[post.pk]) @@ -208,10 +186,8 @@ class PostDetailViewTestCase(TestCase): self.assertEquals(response.status_code, 403) def test_mark_read(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - post = PostFactory(rule=rule, read=False) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = FeedPostFactory(rule=rule, read=False) response = self.client.patch( reverse("api:news:core:posts-detail", args=[post.pk]), @@ -224,10 +200,8 @@ class PostDetailViewTestCase(TestCase): self.assertEquals(data["read"], True) def test_mark_unread(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - post = PostFactory(rule=rule, read=True) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = FeedPostFactory(rule=rule, read=True) response = self.client.patch( reverse("api:news:core:posts-detail", args=[post.pk]), diff --git a/src/newsreader/news/core/tests/endpoints/post/list/tests.py b/src/newsreader/news/core/tests/endpoints/post/list/tests.py index 3800b64..3bf9d17 100644 --- a/src/newsreader/news/core/tests/endpoints/post/list/tests.py +++ b/src/newsreader/news/core/tests/endpoints/post/list/tests.py @@ -6,8 +6,8 @@ from django.urls import reverse import pytz from newsreader.accounts.tests.factories import UserFactory -from newsreader.news.collection.tests.factories import CollectionRuleFactory -from newsreader.news.core.tests.factories import CategoryFactory, PostFactory +from newsreader.news.collection.tests.factories import FeedFactory +from newsreader.news.core.tests.factories import CategoryFactory, FeedPostFactory class PostListViewTestCase(TestCase): @@ -16,10 +16,8 @@ class PostListViewTestCase(TestCase): self.client.force_login(self.user) def test_simple(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - PostFactory.create_batch(size=3, rule=rule) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + FeedPostFactory.create_batch(size=3, rule=rule) response = self.client.get(reverse("api:news:core:posts-list")) data = response.json() @@ -30,26 +28,24 @@ class PostListViewTestCase(TestCase): self.assertEquals(data["count"], 3) def test_ordering(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) posts = [ - PostFactory( + FeedPostFactory( title="I'm the first post", rule=rule, publication_date=datetime.combine( date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc ), ), - PostFactory( + FeedPostFactory( title="I'm the second post", rule=rule, publication_date=datetime.combine( date(2019, 7, 20), time(hour=18, minute=7, second=37), pytz.utc ), ), - PostFactory( + FeedPostFactory( title="I'm the third post", rule=rule, publication_date=datetime.combine( @@ -71,10 +67,8 @@ class PostListViewTestCase(TestCase): self.assertEquals(data["results"][2]["id"], posts[0].pk) def test_pagination_count(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - PostFactory.create_batch(size=80, rule=rule) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + FeedPostFactory.create_batch(size=80, rule=rule) page_size = 50 response = self.client.get(reverse("api:news:core:posts-list"), {"count": 50}) @@ -126,7 +120,7 @@ class PostListViewTestCase(TestCase): def test_posts_with_unauthenticated_user_without_category(self): self.client.logout() - PostFactory.create_batch(size=3, rule=CollectionRuleFactory(user=self.user)) + FeedPostFactory.create_batch(size=3, rule=FeedFactory(user=self.user)) response = self.client.get(reverse("api:news:core:posts-list")) @@ -137,8 +131,8 @@ class PostListViewTestCase(TestCase): category = CategoryFactory(user=self.user) - PostFactory.create_batch( - size=3, rule=CollectionRuleFactory(user=self.user, category=category) + FeedPostFactory.create_batch( + size=3, rule=FeedFactory(user=self.user, category=category) ) response = self.client.get(reverse("api:news:core:posts-list")) @@ -148,8 +142,8 @@ class PostListViewTestCase(TestCase): def test_posts_with_unauthorized_user_without_category(self): other_user = UserFactory() - rule = CollectionRuleFactory(user=other_user, category=None) - PostFactory.create_batch(size=3, rule=rule) + rule = FeedFactory(user=other_user, category=None) + FeedPostFactory.create_batch(size=3, rule=rule) response = self.client.get(reverse("api:news:core:posts-list")) data = response.json() @@ -162,8 +156,8 @@ class PostListViewTestCase(TestCase): other_user = UserFactory() category = CategoryFactory(user=other_user) - PostFactory.create_batch( - size=3, rule=CollectionRuleFactory(user=other_user, category=category) + FeedPostFactory.create_batch( + size=3, rule=FeedFactory(user=other_user, category=category) ) response = self.client.get(reverse("api:news:core:posts-list")) @@ -178,10 +172,8 @@ class PostListViewTestCase(TestCase): def test_posts_with_authorized_rule_unauthorized_category(self): other_user = UserFactory() - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=other_user) - ) - PostFactory.create_batch(size=3, rule=rule) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=other_user)) + FeedPostFactory.create_batch(size=3, rule=rule) response = self.client.get(reverse("api:news:core:posts-list")) data = response.json() @@ -192,8 +184,8 @@ class PostListViewTestCase(TestCase): self.assertEquals(data["count"], 0) def test_posts_with_authorized_user_without_category(self): - rule = CollectionRuleFactory(user=self.user, category=None) - PostFactory.create_batch(size=3, rule=rule) + rule = FeedFactory(user=self.user, category=None) + FeedPostFactory.create_batch(size=3, rule=rule) response = self.client.get(reverse("api:news:core:posts-list")) data = response.json() @@ -204,12 +196,10 @@ class PostListViewTestCase(TestCase): self.assertEquals(data["count"], 3) def test_unread_posts(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) - PostFactory.create_batch(size=10, rule=rule, read=False) - PostFactory.create_batch(size=10, rule=rule, read=True) + FeedPostFactory.create_batch(size=10, rule=rule, read=False) + FeedPostFactory.create_batch(size=10, rule=rule, read=True) response = self.client.get( reverse("api:news:core:posts-list"), {"read": "false"} @@ -225,12 +215,10 @@ class PostListViewTestCase(TestCase): self.assertEquals(post["read"], False) def test_read_posts(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) - PostFactory.create_batch(size=20, rule=rule, read=False) - PostFactory.create_batch(size=10, rule=rule, read=True) + FeedPostFactory.create_batch(size=20, rule=rule, read=False) + FeedPostFactory.create_batch(size=10, rule=rule, read=True) response = self.client.get( reverse("api:news:core:posts-list"), {"read": "true"} diff --git a/src/newsreader/news/core/tests/factories.py b/src/newsreader/news/core/tests/factories.py index 966e70b..182db0e 100644 --- a/src/newsreader/news/core/tests/factories.py +++ b/src/newsreader/news/core/tests/factories.py @@ -33,8 +33,11 @@ class PostFactory(factory.django.DjangoModelFactory): model = Post +class FeedPostFactory(PostFactory): + rule = factory.SubFactory("newsreader.news.collection.tests.factories.FeedFactory") + + class RedditPostFactory(PostFactory): - remote_identifier = factory.Faker("uuid4") url = factory.fuzzy.FuzzyText(length=10, prefix=f"{REDDIT_URL}/") rule = factory.SubFactory( "newsreader.news.collection.tests.factories.SubredditFactory" From 0f5e9e7fca4ca0df3397c86adea689e88f8cc1ba Mon Sep 17 00:00:00 2001 From: sonny Date: Wed, 22 Jul 2020 22:56:00 +0200 Subject: [PATCH 128/422] Fix post modal rules not linking properly --- .../js/pages/homepage/components/PostModal.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/newsreader/js/pages/homepage/components/PostModal.js b/src/newsreader/js/pages/homepage/components/PostModal.js index acc700a..08033bc 100644 --- a/src/newsreader/js/pages/homepage/components/PostModal.js +++ b/src/newsreader/js/pages/homepage/components/PostModal.js @@ -3,6 +3,7 @@ import { connect } from 'react-redux'; import Cookies from 'js-cookie'; import { unSelectPost, markPostRead } from '../actions/posts.js'; +import { CATEGORY_TYPE, RULE_TYPE, FEED, SUBREDDIT } from '../constants.js'; import { formatDatetime } from '../../../utils.js'; class PostModal extends React.Component { @@ -43,6 +44,10 @@ class PostModal extends React.Component { const post = this.props.post; const publicationDate = formatDatetime(post.publicationDate); const titleClassName = post.read ? 'post__title post__title--read' : 'post__title'; + const ruleUrl = + this.props.rule.type === FEED + ? `/collection/rules/${this.props.rule.id}/` + : `/collection/rules/subreddits/${this.props.rule.id}/`; return (
      @@ -70,11 +75,7 @@ class PostModal extends React.Component { )} - + {this.props.rule.name} From 9a212c6288633bd149b0463b58be3fa423b58a6b Mon Sep 17 00:00:00 2001 From: sonny Date: Wed, 22 Jul 2020 23:29:23 +0200 Subject: [PATCH 129/422] Fix reddit form urls --- src/newsreader/news/collection/forms.py | 23 ++++++++++------ .../tests/views/test_subreddit_views.py | 27 ++++++++++++++----- src/newsreader/news/core/tests/factories.py | 4 +-- 3 files changed, 38 insertions(+), 16 deletions(-) diff --git a/src/newsreader/news/collection/forms.py b/src/newsreader/news/collection/forms.py index a8aac52..604500d 100644 --- a/src/newsreader/news/collection/forms.py +++ b/src/newsreader/news/collection/forms.py @@ -1,4 +1,5 @@ from django import forms +from django.core.exceptions import ValidationError from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ @@ -6,15 +7,17 @@ import pytz from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.reddit import REDDIT_API_URL from newsreader.news.core.models import Category def get_reddit_help_text(): return mark_safe( - "Only subreddits are supported. For example: " - "https://www.reddit.com/r/aww." - " Note that subreddit urls should NOT include 'www' because Reddit is picky." + "Only subreddits are supported" + " see the 'listings' section in the reddit API docs." + " For example: https://oauth.reddit.com/r/aww" ) @@ -65,15 +68,19 @@ class SubRedditRuleForm(CollectionRuleForm): timezone = None + def clean_url(self): + url = self.cleaned_data["url"] + + if not url.startswith(REDDIT_API_URL): + raise ValidationError(_("This does not look like an Reddit API URL")) + + return url + def save(self, commit=True): instance = super().save(commit=False) instance.type = RuleTypeChoices.subreddit instance.timezone = str(pytz.utc) - instance.user = self.user - - if not instance.url.endswith(".json"): - instance.url = f"{instance.url}.json" if commit: instance.save() diff --git a/src/newsreader/news/collection/tests/views/test_subreddit_views.py b/src/newsreader/news/collection/tests/views/test_subreddit_views.py index a8de55e..0dff663 100644 --- a/src/newsreader/news/collection/tests/views/test_subreddit_views.py +++ b/src/newsreader/news/collection/tests/views/test_subreddit_views.py @@ -1,11 +1,12 @@ from django.test import TestCase from django.urls import reverse +from django.utils.translation import gettext as _ import pytz from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.models import CollectionRule -from newsreader.news.collection.reddit import REDDIT_URL +from newsreader.news.collection.reddit import REDDIT_API_URL, REDDIT_URL from newsreader.news.collection.tests.factories import SubredditFactory from newsreader.news.collection.tests.views.base import CollectionRuleViewTestCase from newsreader.news.core.tests.factories import CategoryFactory @@ -17,7 +18,7 @@ class SubRedditCreateViewTestCase(CollectionRuleViewTestCase, TestCase): self.form_data = { "name": "new rule", - "url": "https://www.reddit.com/r/aww", + "url": f"{REDDIT_API_URL}/r/aww", "category": str(self.category.pk), } @@ -31,12 +32,19 @@ class SubRedditCreateViewTestCase(CollectionRuleViewTestCase, TestCase): rule = CollectionRule.objects.get(name="new rule") self.assertEquals(rule.type, RuleTypeChoices.subreddit) - self.assertEquals(rule.url, "https://www.reddit.com/r/aww.json") + self.assertEquals(rule.url, f"{REDDIT_API_URL}/r/aww") self.assertEquals(rule.timezone, str(pytz.utc)) self.assertEquals(rule.favicon, None) self.assertEquals(rule.category.pk, self.category.pk) self.assertEquals(rule.user.pk, self.user.pk) + def test_regular_reddit_url(self): + self.form_data.update(url=f"{REDDIT_URL}/r/aww") + + response = self.client.post(self.url, self.form_data) + + self.assertContains(response, _("This does not look like an Reddit API URL")) + class SubRedditUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): def setUp(self): @@ -44,7 +52,7 @@ class SubRedditUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): self.rule = SubredditFactory( name="Python", - url=f"{REDDIT_URL}/r/python.json", + url=f"{REDDIT_API_URL}/r/python.json", user=self.user, category=self.category, type=RuleTypeChoices.subreddit, @@ -97,7 +105,7 @@ class SubRedditUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): self.assertEquals(response.status_code, 404) def test_url_change(self): - self.form_data.update(name="aww", url=f"{REDDIT_URL}/r/aww") + self.form_data.update(name="aww", url=f"{REDDIT_API_URL}/r/aww") response = self.client.post(self.url, self.form_data) @@ -106,8 +114,15 @@ class SubRedditUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): rule = CollectionRule.objects.get(name="aww") self.assertEquals(rule.type, RuleTypeChoices.subreddit) - self.assertEquals(rule.url, f"{REDDIT_URL}/r/aww.json") + self.assertEquals(rule.url, f"{REDDIT_API_URL}/r/aww") self.assertEquals(rule.timezone, str(pytz.utc)) self.assertEquals(rule.favicon, None) self.assertEquals(rule.category.pk, self.category.pk) self.assertEquals(rule.user.pk, self.user.pk) + + def test_regular_reddit_url(self): + self.form_data.update(url=f"{REDDIT_URL}/r/aww") + + response = self.client.post(self.url, self.form_data) + + self.assertContains(response, _("This does not look like an Reddit API URL")) diff --git a/src/newsreader/news/core/tests/factories.py b/src/newsreader/news/core/tests/factories.py index 182db0e..520f940 100644 --- a/src/newsreader/news/core/tests/factories.py +++ b/src/newsreader/news/core/tests/factories.py @@ -3,7 +3,7 @@ import factory.fuzzy import pytz from newsreader.accounts.tests.factories import UserFactory -from newsreader.news.collection.reddit import REDDIT_URL +from newsreader.news.collection.reddit import REDDIT_API_URL from newsreader.news.core.models import Category, Post @@ -38,7 +38,7 @@ class FeedPostFactory(PostFactory): class RedditPostFactory(PostFactory): - url = factory.fuzzy.FuzzyText(length=10, prefix=f"{REDDIT_URL}/") + url = factory.fuzzy.FuzzyText(length=10, prefix=f"{REDDIT_API_URL}/") rule = factory.SubFactory( "newsreader.news.collection.tests.factories.SubredditFactory" ) From e73edd5083206876a5ae3d6375c399a08aff7782 Mon Sep 17 00:00:00 2001 From: sonny Date: Wed, 22 Jul 2020 23:39:37 +0200 Subject: [PATCH 130/422] Adjust reddit token expiry loglevel Fixes #51 --- src/newsreader/news/collection/reddit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/newsreader/news/collection/reddit.py b/src/newsreader/news/collection/reddit.py index 9081a29..557271c 100644 --- a/src/newsreader/news/collection/reddit.py +++ b/src/newsreader/news/collection/reddit.py @@ -320,7 +320,7 @@ class RedditClient(Client): yield response_data except StreamDeniedException as e: - logger.exception( + logger.warning( f"Access token expired for user {stream.user.pk}" ) From a6827e604de2c9bbbeebba386301f0d86e5a18ec Mon Sep 17 00:00:00 2001 From: sonny Date: Wed, 22 Jul 2020 23:49:18 +0200 Subject: [PATCH 131/422] Replace rest_framework_swagger with drf_yasg rest_framework is deprecated see https://github.com/marcgibbons/django-rest-swagger#django-rest-swagger-deprecated-2019-06-04 --- src/newsreader/js/pages/homepage/App.js | 4 +- .../js/pages/homepage/components/PostModal.js | 11 +- .../{feedlist => postlist}/PostItem.js | 13 +- .../FeedList.js => postlist/PostList.js} | 4 +- .../{feedlist => postlist}/filters.js | 0 src/newsreader/js/pages/homepage/constants.js | 3 + src/newsreader/news/collection/admin.py | 12 +- src/newsreader/news/collection/forms.py | 23 +- src/newsreader/news/collection/reddit.py | 93 +- src/newsreader/news/collection/serializers.py | 2 +- .../tests/endpoints/rule/detail/tests.py | 51 +- .../tests/endpoints/rule/list/tests.py | 70 +- .../collection/tests/feed/builder/tests.py | 6 +- .../collection/tests/feed/collector/tests.py | 14 +- .../tests/feed/duplicate_handler/tests.py | 40 +- .../collection/tests/reddit/builder/mocks.py | 2191 ++++++++++++++++- .../collection/tests/reddit/builder/tests.py | 201 +- .../tests/views/test_subreddit_views.py | 27 +- .../tests/endpoints/category/detail/tests.py | 24 +- .../tests/endpoints/category/list/tests.py | 76 +- .../core/tests/endpoints/post/detail/tests.py | 88 +- .../core/tests/endpoints/post/list/tests.py | 66 +- src/newsreader/news/core/tests/factories.py | 9 +- .../scss/components/post/_post.scss | 11 +- 24 files changed, 2728 insertions(+), 311 deletions(-) rename src/newsreader/js/pages/homepage/components/{feedlist => postlist}/PostItem.js (83%) rename src/newsreader/js/pages/homepage/components/{feedlist/FeedList.js => postlist/PostList.js} (95%) rename src/newsreader/js/pages/homepage/components/{feedlist => postlist}/filters.js (100%) diff --git a/src/newsreader/js/pages/homepage/App.js b/src/newsreader/js/pages/homepage/App.js index bdf0149..91cfa4e 100644 --- a/src/newsreader/js/pages/homepage/App.js +++ b/src/newsreader/js/pages/homepage/App.js @@ -6,7 +6,7 @@ import { isEqual } from 'lodash'; import { fetchCategories } from './actions/categories'; import Sidebar from './components/sidebar/Sidebar.js'; -import FeedList from './components/feedlist/FeedList.js'; +import PostList from './components/postlist/PostList.js'; import PostModal from './components/PostModal.js'; import Messages from '../../components/Messages.js'; @@ -19,7 +19,7 @@ class App extends React.Component { return ( <> - + {this.props.error && ( diff --git a/src/newsreader/js/pages/homepage/components/PostModal.js b/src/newsreader/js/pages/homepage/components/PostModal.js index acc700a..08033bc 100644 --- a/src/newsreader/js/pages/homepage/components/PostModal.js +++ b/src/newsreader/js/pages/homepage/components/PostModal.js @@ -3,6 +3,7 @@ import { connect } from 'react-redux'; import Cookies from 'js-cookie'; import { unSelectPost, markPostRead } from '../actions/posts.js'; +import { CATEGORY_TYPE, RULE_TYPE, FEED, SUBREDDIT } from '../constants.js'; import { formatDatetime } from '../../../utils.js'; class PostModal extends React.Component { @@ -43,6 +44,10 @@ class PostModal extends React.Component { const post = this.props.post; const publicationDate = formatDatetime(post.publicationDate); const titleClassName = post.read ? 'post__title post__title--read' : 'post__title'; + const ruleUrl = + this.props.rule.type === FEED + ? `/collection/rules/${this.props.rule.id}/` + : `/collection/rules/subreddits/${this.props.rule.id}/`; return (
      @@ -70,11 +75,7 @@ class PostModal extends React.Component { )} - + {this.props.rule.name} diff --git a/src/newsreader/js/pages/homepage/components/feedlist/PostItem.js b/src/newsreader/js/pages/homepage/components/postlist/PostItem.js similarity index 83% rename from src/newsreader/js/pages/homepage/components/feedlist/PostItem.js rename to src/newsreader/js/pages/homepage/components/postlist/PostItem.js index a796916..9b64289 100644 --- a/src/newsreader/js/pages/homepage/components/feedlist/PostItem.js +++ b/src/newsreader/js/pages/homepage/components/postlist/PostItem.js @@ -1,7 +1,7 @@ import React from 'react'; import { connect } from 'react-redux'; -import { CATEGORY_TYPE, RULE_TYPE } from '../../constants.js'; +import { CATEGORY_TYPE, RULE_TYPE, FEED, SUBREDDIT } from '../../constants.js'; import { selectPost } from '../../actions/posts.js'; import { formatDatetime } from '../../../../utils.js'; @@ -14,6 +14,11 @@ class PostItem extends React.Component { ? 'posts__header posts__header--read' : 'posts__header'; + const ruleUrl = + rule.type === FEED + ? `/collection/rules/${rule.id}/` + : `/collection/rules/subreddits/${rule.id}/`; + return (
    • {this.props.selected.type == CATEGORY_TYPE && ( - + {rule.name} diff --git a/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js b/src/newsreader/js/pages/homepage/components/postlist/PostList.js similarity index 95% rename from src/newsreader/js/pages/homepage/components/feedlist/FeedList.js rename to src/newsreader/js/pages/homepage/components/postlist/PostList.js index e679eed..cd57d6d 100644 --- a/src/newsreader/js/pages/homepage/components/feedlist/FeedList.js +++ b/src/newsreader/js/pages/homepage/components/postlist/PostList.js @@ -8,7 +8,7 @@ import { filterPosts } from './filters.js'; import LoadingIndicator from '../../../../components/LoadingIndicator.js'; import PostItem from './PostItem.js'; -class FeedList extends React.Component { +class PostList extends React.Component { checkScrollHeight = ::this.checkScrollHeight; componentDidMount() { @@ -83,4 +83,4 @@ const mapDispatchToProps = dispatch => ({ fetchPostsBySection: (rule, page = false) => dispatch(fetchPostsBySection(rule, page)), }); -export default connect(mapStateToProps, mapDispatchToProps)(FeedList); +export default connect(mapStateToProps, mapDispatchToProps)(PostList); diff --git a/src/newsreader/js/pages/homepage/components/feedlist/filters.js b/src/newsreader/js/pages/homepage/components/postlist/filters.js similarity index 100% rename from src/newsreader/js/pages/homepage/components/feedlist/filters.js rename to src/newsreader/js/pages/homepage/components/postlist/filters.js diff --git a/src/newsreader/js/pages/homepage/constants.js b/src/newsreader/js/pages/homepage/constants.js index 0e3f3d3..66b6365 100644 --- a/src/newsreader/js/pages/homepage/constants.js +++ b/src/newsreader/js/pages/homepage/constants.js @@ -1,2 +1,5 @@ export const RULE_TYPE = 'RULE'; export const CATEGORY_TYPE = 'CATEGORY'; + +export const SUBREDDIT = 'subreddit'; +export const FEED = 'feed'; diff --git a/src/newsreader/news/collection/admin.py b/src/newsreader/news/collection/admin.py index e82dea5..c5a7c5c 100644 --- a/src/newsreader/news/collection/admin.py +++ b/src/newsreader/news/collection/admin.py @@ -6,7 +6,14 @@ from newsreader.news.collection.models import CollectionRule class CollectionRuleAdmin(admin.ModelAdmin): fields = ("url", "name", "timezone", "category", "favicon", "user") - list_display = ("name", "category", "url", "last_suceeded", "succeeded") + list_display = ( + "name", + "type_display", + "category", + "url", + "last_suceeded", + "succeeded", + ) list_filter = ("user",) def save_model(self, request, obj, form, change): @@ -14,5 +21,8 @@ class CollectionRuleAdmin(admin.ModelAdmin): obj.user = request.user obj.save() + def type_display(self, rule): + return rule.get_type_display() + admin.site.register(CollectionRule, CollectionRuleAdmin) diff --git a/src/newsreader/news/collection/forms.py b/src/newsreader/news/collection/forms.py index a8aac52..604500d 100644 --- a/src/newsreader/news/collection/forms.py +++ b/src/newsreader/news/collection/forms.py @@ -1,4 +1,5 @@ from django import forms +from django.core.exceptions import ValidationError from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ @@ -6,15 +7,17 @@ import pytz from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.reddit import REDDIT_API_URL from newsreader.news.core.models import Category def get_reddit_help_text(): return mark_safe( - "Only subreddits are supported. For example: " - "https://www.reddit.com/r/aww." - " Note that subreddit urls should NOT include 'www' because Reddit is picky." + "Only subreddits are supported" + " see the 'listings' section in the reddit API docs." + " For example: https://oauth.reddit.com/r/aww" ) @@ -65,15 +68,19 @@ class SubRedditRuleForm(CollectionRuleForm): timezone = None + def clean_url(self): + url = self.cleaned_data["url"] + + if not url.startswith(REDDIT_API_URL): + raise ValidationError(_("This does not look like an Reddit API URL")) + + return url + def save(self, commit=True): instance = super().save(commit=False) instance.type = RuleTypeChoices.subreddit instance.timezone = str(pytz.utc) - instance.user = self.user - - if not instance.url.endswith(".json"): - instance.url = f"{instance.url}.json" if commit: instance.save() diff --git a/src/newsreader/news/collection/reddit.py b/src/newsreader/news/collection/reddit.py index 1e2837b..557271c 100644 --- a/src/newsreader/news/collection/reddit.py +++ b/src/newsreader/news/collection/reddit.py @@ -10,6 +10,7 @@ from uuid import uuid4 from django.conf import settings from django.core.cache import cache from django.utils import timezone +from django.utils.html import format_html import bleach import pytz @@ -42,6 +43,12 @@ REDDIT_API_URL = "https://oauth.reddit.com" RATE_LIMIT = 60 RATE_LIMIT_DURATION = timedelta(seconds=60) +REDDIT_IMAGE_EXTENSIONS = (".jpg", ".png", ".gif") +REDDIT_VIDEO_EXTENSIONS = (".mp4", ".gifv", ".webm") + +# see type prefixes on https://www.reddit.com/dev/api/ +REDDIT_POST = "t3" + def get_reddit_authorization_url(user): state = str(uuid4()) @@ -114,30 +121,72 @@ class RedditBuilder(Builder): results = {} for post in posts: - if not "data" in post: + if not "data" in post or post["kind"] != REDDIT_POST: continue - remote_identifier = post["data"]["id"] - title = truncate_text(Post, "title", post["data"]["title"]) - author = truncate_text(Post, "author", post["data"]["author"]) - url_fragment = f"{post['data']['permalink']}" + data = post["data"] + + remote_identifier = data["id"] + title = truncate_text(Post, "title", data["title"]) + author = truncate_text(Post, "author", data["author"]) + post_url_fragment = data["permalink"] + direct_url = data["url"] + is_text_post = data["is_self"] if remote_identifier in results: continue - uncleaned_body = post["data"]["selftext_html"] - unescaped_body = unescape(uncleaned_body) if uncleaned_body else "" - body = ( - bleach.clean( - unescaped_body, - tags=WHITELISTED_TAGS, - attributes=WHITELISTED_ATTRIBUTES, - strip=True, - strip_comments=True, + if is_text_post: + uncleaned_body = data["selftext_html"] + unescaped_body = unescape(uncleaned_body) if uncleaned_body else "" + body = ( + bleach.clean( + unescaped_body, + tags=WHITELISTED_TAGS, + attributes=WHITELISTED_ATTRIBUTES, + strip=True, + strip_comments=True, + ) + if unescaped_body + else "" + ) + elif direct_url.endswith(REDDIT_IMAGE_EXTENSIONS): + body = format_html( + "
      {title}
      ", + url=direct_url, + title=title, + ) + elif data["is_video"]: + video_info = data["secure_media"]["reddit_video"] + + body = format_html( + "
      ", + url=video_info["fallback_url"], + ) + elif direct_url.endswith(REDDIT_VIDEO_EXTENSIONS): + extension = next( + extension.replace(".", "") + for extension in REDDIT_VIDEO_EXTENSIONS + if direct_url.endswith(extension) + ) + + if extension == "gifv": + body = format_html( + "
      ", + url=direct_url.replace(extension, "mp4"), + ) + else: + body = format_html( + "
      ", + url=direct_url, + extension=extension, + ) + else: + body = format_html( + "", + url=direct_url, + title=title, ) - if unescaped_body - else "" - ) try: parsed_date = datetime.fromtimestamp(post["data"]["created_utc"]) @@ -146,12 +195,12 @@ class RedditBuilder(Builder): logging.warning(f"Failed parsing timestamp from {url_fragment}") created_date = timezone.now() - data = { + post_data = { "remote_identifier": remote_identifier, "title": title, "body": body, "author": author, - "url": f"{REDDIT_URL}{url_fragment}", + "url": f"{REDDIT_URL}{post_url_fragment}", "publication_date": created_date, "rule": rule, } @@ -159,13 +208,13 @@ class RedditBuilder(Builder): if remote_identifier in self.existing_posts: existing_post = self.existing_posts[remote_identifier] - for key, value in data.items(): + for key, value in post_data.items(): setattr(existing_post, key, value) results[existing_post.remote_identifier] = existing_post continue - results[remote_identifier] = Post(**data) + results[remote_identifier] = Post(**post_data) return results.values() @@ -271,7 +320,7 @@ class RedditClient(Client): yield response_data except StreamDeniedException as e: - logger.exception( + logger.warning( f"Access token expired for user {stream.user.pk}" ) diff --git a/src/newsreader/news/collection/serializers.py b/src/newsreader/news/collection/serializers.py index 640d16e..04bdba5 100644 --- a/src/newsreader/news/collection/serializers.py +++ b/src/newsreader/news/collection/serializers.py @@ -12,4 +12,4 @@ class RuleSerializer(serializers.ModelSerializer): class Meta: model = CollectionRule - fields = ("id", "name", "url", "favicon", "category", "user", "unread") + fields = ("id", "type", "name", "url", "favicon", "category", "user", "unread") diff --git a/src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py b/src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py index 02f7334..8dfe6ed 100644 --- a/src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py +++ b/src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py @@ -4,9 +4,9 @@ from django.test import TestCase from django.urls import reverse from newsreader.accounts.tests.factories import UserFactory -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory from newsreader.news.core.models import Post -from newsreader.news.core.tests.factories import CategoryFactory, PostFactory +from newsreader.news.core.tests.factories import CategoryFactory, FeedPostFactory class CollectionRuleDetailViewTestCase(TestCase): @@ -15,7 +15,7 @@ class CollectionRuleDetailViewTestCase(TestCase): self.client.force_login(self.user) def test_simple(self): - rule = CollectionRuleFactory(user=self.user) + rule = FeedFactory(user=self.user) response = self.client.get( reverse("api:news:collection:rules-detail", args=[rule.pk]) @@ -29,6 +29,7 @@ class CollectionRuleDetailViewTestCase(TestCase): self.assertTrue("url" in data) self.assertTrue("favicon" in data) self.assertTrue("category" in data) + self.assertTrue("type" in data) def test_not_known(self): response = self.client.get( @@ -40,7 +41,7 @@ class CollectionRuleDetailViewTestCase(TestCase): self.assertEquals(data["detail"], "Not found.") def test_post(self): - rule = CollectionRuleFactory(user=self.user) + rule = FeedFactory(user=self.user) response = self.client.post( reverse("api:news:collection:rules-detail", args=[rule.pk]) @@ -51,7 +52,7 @@ class CollectionRuleDetailViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "POST" not allowed.') def test_patch(self): - rule = CollectionRuleFactory(name="BBC", user=self.user) + rule = FeedFactory(name="BBC", user=self.user) response = self.client.patch( reverse("api:news:collection:rules-detail", args=[rule.pk]), @@ -67,7 +68,7 @@ class CollectionRuleDetailViewTestCase(TestCase): old_category = CategoryFactory(user=self.user) new_category = CategoryFactory(user=self.user) - rule = CollectionRuleFactory(name="BBC", category=old_category, user=self.user) + rule = FeedFactory(name="BBC", category=old_category, user=self.user) response = self.client.patch( reverse("api:news:collection:rules-detail", args=[rule.pk]), @@ -80,7 +81,7 @@ class CollectionRuleDetailViewTestCase(TestCase): self.assertEquals(data["category"], new_category.pk) def test_identifier_cannot_be_changed(self): - rule = CollectionRuleFactory(user=self.user) + rule = FeedFactory(user=self.user) response = self.client.patch( reverse("api:news:collection:rules-detail", args=[rule.pk]), @@ -93,7 +94,7 @@ class CollectionRuleDetailViewTestCase(TestCase): self.assertEquals(data["id"], rule.pk) def test_category_change(self): - rule = CollectionRuleFactory(user=self.user) + rule = FeedFactory(user=self.user) category = CategoryFactory(user=self.user) response = self.client.patch( @@ -108,7 +109,7 @@ class CollectionRuleDetailViewTestCase(TestCase): self.assertEquals(data["category"], category.pk) def test_put(self): - rule = CollectionRuleFactory(name="BBC", user=self.user) + rule = FeedFactory(name="BBC", user=self.user) response = self.client.put( reverse("api:news:collection:rules-detail", args=[rule.pk]), @@ -121,7 +122,7 @@ class CollectionRuleDetailViewTestCase(TestCase): self.assertEquals(data["name"], "BBC") def test_delete(self): - rule = CollectionRuleFactory(user=self.user) + rule = FeedFactory(user=self.user) response = self.client.delete( reverse("api:news:collection:rules-detail", args=[rule.pk]) @@ -132,7 +133,7 @@ class CollectionRuleDetailViewTestCase(TestCase): def test_rule_with_unauthenticated_user(self): self.client.logout() - rule = CollectionRuleFactory(name="BBC", user=self.user) + rule = FeedFactory(name="BBC", user=self.user) response = self.client.patch( reverse("api:news:collection:rules-detail", args=[rule.pk]), @@ -144,7 +145,7 @@ class CollectionRuleDetailViewTestCase(TestCase): def test_rule_with_unauthorized_user(self): other_user = UserFactory() - rule = CollectionRuleFactory(name="BBC", user=other_user) + rule = FeedFactory(name="BBC", user=other_user) response = self.client.patch( reverse("api:news:collection:rules-detail", args=[rule.pk]), @@ -155,10 +156,10 @@ class CollectionRuleDetailViewTestCase(TestCase): self.assertEquals(response.status_code, 403) def test_read_count(self): - rule = CollectionRuleFactory(user=self.user) + rule = FeedFactory(user=self.user) - PostFactory.create_batch(size=20, read=False, rule=rule) - PostFactory.create_batch(size=20, read=True, rule=rule) + FeedPostFactory.create_batch(size=20, read=False, rule=rule) + FeedPostFactory.create_batch(size=20, read=True, rule=rule) response = self.client.get( reverse("api:news:collection:rules-detail", args=[rule.pk]) @@ -175,9 +176,9 @@ class CollectionRuleReadTestCase(TestCase): self.client.force_login(self.user) def test_rule_read(self): - rule = CollectionRuleFactory(user=self.user) + rule = FeedFactory(user=self.user) - PostFactory.create_batch(size=20, read=False, rule=rule) + FeedPostFactory.create_batch(size=20, read=False, rule=rule) response = self.client.post( reverse("api:news:collection:rules-read", args=[rule.pk]) @@ -197,9 +198,9 @@ class CollectionRuleReadTestCase(TestCase): def test_unauthenticated_user(self): self.client.logout() - rule = CollectionRuleFactory(user=self.user) + rule = FeedFactory(user=self.user) - PostFactory.create_batch(size=20, read=False, rule=rule) + FeedPostFactory.create_batch(size=20, read=False, rule=rule) response = self.client.post( reverse("api:news:collection:rules-read", args=[rule.pk]) @@ -209,9 +210,9 @@ class CollectionRuleReadTestCase(TestCase): def test_unauthorized_user(self): other_user = UserFactory() - rule = CollectionRuleFactory(user=other_user) + rule = FeedFactory(user=other_user) - PostFactory.create_batch(size=20, read=False, rule=rule) + FeedPostFactory.create_batch(size=20, read=False, rule=rule) response = self.client.post( reverse("api:news:collection:rules-read", args=[rule.pk]) @@ -221,7 +222,7 @@ class CollectionRuleReadTestCase(TestCase): self.assertEquals(Post.objects.filter(read=False).count(), 20) def test_get(self): - rule = CollectionRuleFactory(user=self.user) + rule = FeedFactory(user=self.user) response = self.client.get( reverse("api:news:collection:rules-read", args=[rule.pk]) @@ -230,7 +231,7 @@ class CollectionRuleReadTestCase(TestCase): self.assertEquals(response.status_code, 405) def test_patch(self): - rule = CollectionRuleFactory(name="BBC", user=self.user) + rule = FeedFactory(name="BBC", user=self.user) response = self.client.patch( reverse("api:news:collection:rules-read", args=[rule.pk]), @@ -241,7 +242,7 @@ class CollectionRuleReadTestCase(TestCase): self.assertEquals(response.status_code, 405) def test_put(self): - rule = CollectionRuleFactory(name="BBC", user=self.user) + rule = FeedFactory(name="BBC", user=self.user) response = self.client.put( reverse("api:news:collection:rules-read", args=[rule.pk]), @@ -252,7 +253,7 @@ class CollectionRuleReadTestCase(TestCase): self.assertEquals(response.status_code, 405) def test_delete(self): - rule = CollectionRuleFactory(user=self.user) + rule = FeedFactory(user=self.user) response = self.client.delete( reverse("api:news:collection:rules-read", args=[rule.pk]) diff --git a/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py b/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py index 19d2029..4d1ba8f 100644 --- a/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py +++ b/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py @@ -8,8 +8,8 @@ from django.urls import reverse import pytz from newsreader.accounts.tests.factories import UserFactory -from newsreader.news.collection.tests.factories import CollectionRuleFactory -from newsreader.news.core.tests.factories import CategoryFactory, PostFactory +from newsreader.news.collection.tests.factories import FeedFactory +from newsreader.news.core.tests.factories import CategoryFactory, FeedPostFactory class RuleListViewTestCase(TestCase): @@ -18,7 +18,7 @@ class RuleListViewTestCase(TestCase): self.client.force_login(self.user) def test_simple(self): - CollectionRuleFactory.create_batch(size=3, user=self.user) + FeedFactory.create_batch(size=3, user=self.user) response = self.client.get(reverse("api:news:collection:rules-list")) data = response.json() @@ -30,19 +30,19 @@ class RuleListViewTestCase(TestCase): def test_ordering(self): rules = [ - CollectionRuleFactory( + FeedFactory( created=datetime.combine( date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc ), user=self.user, ), - CollectionRuleFactory( + FeedFactory( created=datetime.combine( date(2019, 7, 20), time(hour=18, minute=7, second=37), pytz.utc ), user=self.user, ), - CollectionRuleFactory( + FeedFactory( created=datetime.combine( date(2019, 7, 20), time(hour=16, minute=7, second=37), pytz.utc ), @@ -63,7 +63,7 @@ class RuleListViewTestCase(TestCase): self.assertEquals(data["results"][2]["id"], rules[0].pk) def test_pagination_count(self): - CollectionRuleFactory.create_batch(size=80, user=self.user) + FeedFactory.create_batch(size=80, user=self.user) response = self.client.get( reverse("api:news:collection:rules-list"), {"count": 30} @@ -124,7 +124,7 @@ class RuleListViewTestCase(TestCase): def test_rules_with_unauthenticated_user(self): self.client.logout() - CollectionRuleFactory.create_batch(size=3, user=self.user) + FeedFactory.create_batch(size=3, user=self.user) response = self.client.get(reverse("api:news:collection:rules-list")) @@ -132,7 +132,7 @@ class RuleListViewTestCase(TestCase): def test_rules_with_unauthorized_user(self): other_user = UserFactory() - CollectionRuleFactory.create_batch(size=3, user=other_user) + FeedFactory.create_batch(size=3, user=other_user) response = self.client.get(reverse("api:news:collection:rules-list")) data = response.json() @@ -149,8 +149,8 @@ class NestedRuleListViewTestCase(TestCase): self.client.force_login(self.user) def test_simple(self): - rule = CollectionRuleFactory.create(user=self.user) - PostFactory.create_batch(size=5, rule=rule) + rule = FeedFactory.create(user=self.user) + FeedPostFactory.create_batch(size=5, rule=rule) response = self.client.get( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}) @@ -164,8 +164,8 @@ class NestedRuleListViewTestCase(TestCase): self.assertEquals(data["count"], 5) def test_pagination(self): - rule = CollectionRuleFactory.create(user=self.user) - PostFactory.create_batch(size=80, rule=rule) + rule = FeedFactory.create(user=self.user) + FeedPostFactory.create_batch(size=80, rule=rule) response = self.client.get( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}), @@ -178,7 +178,7 @@ class NestedRuleListViewTestCase(TestCase): self.assertEquals(len(data["results"]), 30) def test_empty(self): - rule = CollectionRuleFactory.create(user=self.user) + rule = FeedFactory.create(user=self.user) response = self.client.get( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}) @@ -197,7 +197,7 @@ class NestedRuleListViewTestCase(TestCase): self.assertEquals(response.status_code, 404) def test_post(self): - rule = CollectionRuleFactory.create(user=self.user) + rule = FeedFactory.create(user=self.user) response = self.client.post( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}), @@ -210,7 +210,7 @@ class NestedRuleListViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "POST" not allowed.') def test_patch(self): - rule = CollectionRuleFactory.create(user=self.user) + rule = FeedFactory.create(user=self.user) response = self.client.patch( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}), @@ -223,7 +223,7 @@ class NestedRuleListViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "PATCH" not allowed.') def test_put(self): - rule = CollectionRuleFactory.create(user=self.user) + rule = FeedFactory.create(user=self.user) response = self.client.put( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}), @@ -236,7 +236,7 @@ class NestedRuleListViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "PUT" not allowed.') def test_delete(self): - rule = CollectionRuleFactory.create(user=self.user) + rule = FeedFactory.create(user=self.user) response = self.client.delete( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}), @@ -251,7 +251,7 @@ class NestedRuleListViewTestCase(TestCase): def test_rule_with_unauthenticated_user(self): self.client.logout() - rule = CollectionRuleFactory(user=self.user) + rule = FeedFactory(user=self.user) response = self.client.get( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}) @@ -261,7 +261,7 @@ class NestedRuleListViewTestCase(TestCase): def test_rule_with_unauthorized_user(self): other_user = UserFactory() - rule = CollectionRuleFactory(user=other_user) + rule = FeedFactory(user=other_user) response = self.client.get( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}) @@ -270,26 +270,24 @@ class NestedRuleListViewTestCase(TestCase): self.assertEquals(response.status_code, 403) def test_posts_ordering(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) posts = [ - PostFactory( + FeedPostFactory( title="I'm the first post", rule=rule, publication_date=datetime.combine( date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc ), ), - PostFactory( + FeedPostFactory( title="I'm the second post", rule=rule, publication_date=datetime.combine( date(2019, 7, 20), time(hour=18, minute=7, second=37), pytz.utc ), ), - PostFactory( + FeedPostFactory( title="I'm the third post", rule=rule, publication_date=datetime.combine( @@ -313,11 +311,11 @@ class NestedRuleListViewTestCase(TestCase): self.assertEquals(data["results"][2]["id"], posts[0].pk) def test_only_posts_from_rule_are_returned(self): - rule = CollectionRuleFactory.create(user=self.user) - other_rule = CollectionRuleFactory.create(user=self.user) + rule = FeedFactory.create(user=self.user) + other_rule = FeedFactory.create(user=self.user) - PostFactory.create_batch(size=5, rule=rule) - PostFactory.create_batch(size=5, rule=other_rule) + FeedPostFactory.create_batch(size=5, rule=rule) + FeedPostFactory.create_batch(size=5, rule=other_rule) response = self.client.get( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}) @@ -334,10 +332,10 @@ class NestedRuleListViewTestCase(TestCase): self.assertEquals(post["rule"], rule.pk) def test_unread_posts(self): - rule = CollectionRuleFactory.create(user=self.user) + rule = FeedFactory.create(user=self.user) - PostFactory.create_batch(size=10, rule=rule, read=False) - PostFactory.create_batch(size=10, rule=rule, read=True) + FeedPostFactory.create_batch(size=10, rule=rule, read=False) + FeedPostFactory.create_batch(size=10, rule=rule, read=True) response = self.client.get( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}), @@ -354,10 +352,10 @@ class NestedRuleListViewTestCase(TestCase): self.assertEquals(post["read"], False) def test_read_posts(self): - rule = CollectionRuleFactory.create(user=self.user) + rule = FeedFactory.create(user=self.user) - PostFactory.create_batch(size=20, rule=rule, read=False) - PostFactory.create_batch(size=10, rule=rule, read=True) + FeedPostFactory.create_batch(size=20, rule=rule, read=False) + FeedPostFactory.create_batch(size=10, rule=rule, read=True) response = self.client.get( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}), diff --git a/src/newsreader/news/collection/tests/feed/builder/tests.py b/src/newsreader/news/collection/tests/feed/builder/tests.py index 7069f96..c3e60e0 100644 --- a/src/newsreader/news/collection/tests/feed/builder/tests.py +++ b/src/newsreader/news/collection/tests/feed/builder/tests.py @@ -11,7 +11,7 @@ from freezegun import freeze_time from newsreader.news.collection.feed import FeedBuilder from newsreader.news.collection.tests.factories import FeedFactory from newsreader.news.core.models import Post -from newsreader.news.core.tests.factories import PostFactory +from newsreader.news.core.tests.factories import FeedPostFactory from .mocks import * @@ -287,11 +287,11 @@ class FeedBuilderTestCase(TestCase): rule = FeedFactory() mock_stream = MagicMock(rule=rule) - existing_first_post = PostFactory.create( + existing_first_post = FeedPostFactory.create( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", rule=rule ) - existing_second_post = PostFactory.create( + existing_second_post = FeedPostFactory.create( remote_identifier="a5479c66-8fae-11e9-8422-00163ef6bee7", rule=rule ) diff --git a/src/newsreader/news/collection/tests/feed/collector/tests.py b/src/newsreader/news/collection/tests/feed/collector/tests.py index b0fc7cf..5a1bac1 100644 --- a/src/newsreader/news/collection/tests/feed/collector/tests.py +++ b/src/newsreader/news/collection/tests/feed/collector/tests.py @@ -21,7 +21,7 @@ from newsreader.news.collection.feed import FeedCollector from newsreader.news.collection.tests.factories import FeedFactory from newsreader.news.collection.utils import build_publication_date from newsreader.news.core.models import Post -from newsreader.news.core.tests.factories import PostFactory +from newsreader.news.core.tests.factories import FeedPostFactory from .mocks import duplicate_mock, empty_mock, multiple_mock, multiple_update_mock @@ -143,7 +143,7 @@ class FeedCollectorTestCase(TestCase): struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), pytz.utc ) - first_post = PostFactory( + first_post = FeedPostFactory( url="https://www.bbc.co.uk/news/world-us-canada-48338168", title="Trump's 'genocidal taunts' will not end Iran - Zarif", body="Foreign Minister Mohammad Javad Zarif says the US " @@ -156,7 +156,7 @@ class FeedCollectorTestCase(TestCase): struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0)), pytz.utc ) - second_post = PostFactory( + second_post = FeedPostFactory( url="https://www.bbc.co.uk/news/technology-48334739", title="Huawei's Android loss: How it affects you", body="Google's move to end business ties with Huawei will " @@ -169,7 +169,7 @@ class FeedCollectorTestCase(TestCase): struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)), pytz.utc ) - third_post = PostFactory( + third_post = FeedPostFactory( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", title="Birmingham head teacher threatened over LGBT lessons", body="Police are investigating the messages while an MP " @@ -194,7 +194,7 @@ class FeedCollectorTestCase(TestCase): self.mocked_parse.return_value = multiple_update_mock rule = FeedFactory() - first_post = PostFactory( + first_post = FeedPostFactory( remote_identifier="https://www.bbc.co.uk/news/world-us-canada-48338168", url="https://www.bbc.co.uk/", title="Trump", @@ -203,7 +203,7 @@ class FeedCollectorTestCase(TestCase): rule=rule, ) - second_post = PostFactory( + second_post = FeedPostFactory( remote_identifier="https://www.bbc.co.uk/news/technology-48334739", url="https://www.bbc.co.uk/", title="Huawei's Android loss: How it affects you", @@ -212,7 +212,7 @@ class FeedCollectorTestCase(TestCase): rule=rule, ) - third_post = PostFactory( + third_post = FeedPostFactory( remote_identifier="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", title="Birmingham head teacher threatened over LGBT lessons", diff --git a/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py index 18a6c6c..941de66 100644 --- a/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py +++ b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py @@ -8,7 +8,7 @@ from freezegun import freeze_time from newsreader.news.collection.feed import FeedDuplicateHandler from newsreader.news.collection.tests.factories import FeedFactory from newsreader.news.core.models import Post -from newsreader.news.core.tests.factories import PostFactory +from newsreader.news.core.tests.factories import FeedPostFactory @freeze_time("2019-10-30 12:30:00") @@ -19,17 +19,17 @@ class FeedDuplicateHandlerTestCase(TestCase): def test_duplicate_entries_with_remote_identifiers(self): rule = FeedFactory() - existing_post = PostFactory.create( + existing_post = FeedPostFactory.create( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", rule=rule ) - new_posts = PostFactory.build_batch( + new_posts = FeedPostFactory.build_batch( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", publication_date=timezone.now() - timedelta(days=7), rule=rule, size=5, ) - last_post = PostFactory.build( + last_post = FeedPostFactory.build( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", publication_date=timezone.now(), rule=rule, @@ -54,7 +54,7 @@ class FeedDuplicateHandlerTestCase(TestCase): def test_duplicate_entries_with_different_remote_identifiers(self): rule = FeedFactory() - existing_post = PostFactory( + existing_post = FeedPostFactory( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", url="https://bbc.com", title="New post", @@ -63,7 +63,7 @@ class FeedDuplicateHandlerTestCase(TestCase): rule=rule, ) - new_posts = PostFactory.build_batch( + new_posts = FeedPostFactory.build_batch( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7Q", url="https://bbc.com", title="New post", @@ -72,7 +72,7 @@ class FeedDuplicateHandlerTestCase(TestCase): rule=rule, size=5, ) - last_post = PostFactory.build( + last_post = FeedPostFactory.build( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7Q", url="https://bbc.com", title="New post", @@ -100,7 +100,7 @@ class FeedDuplicateHandlerTestCase(TestCase): def test_duplicate_entries_in_recent_database(self): rule = FeedFactory() - existing_post = PostFactory( + existing_post = FeedPostFactory( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", title="Birmingham head teacher threatened over LGBT lessons", body="Google's move to end business ties with Huawei will affect current devices", @@ -109,7 +109,7 @@ class FeedDuplicateHandlerTestCase(TestCase): rule=rule, ) - new_posts = PostFactory.build_batch( + new_posts = FeedPostFactory.build_batch( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", title="Birmingham head teacher threatened over LGBT lessons", body="Google's move to end business ties with Huawei will affect current devices", @@ -119,7 +119,7 @@ class FeedDuplicateHandlerTestCase(TestCase): size=5, ) - last_post = PostFactory.build( + last_post = FeedPostFactory.build( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", title="Birmingham head teacher threatened over LGBT lessons", body="Google's move to end business ties with Huawei will affect current devices", @@ -147,17 +147,17 @@ class FeedDuplicateHandlerTestCase(TestCase): def test_multiple_existing_entries_with_identifier(self): rule = FeedFactory() - PostFactory.create_batch( + FeedPostFactory.create_batch( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", rule=rule, size=5 ) - new_posts = PostFactory.build_batch( + new_posts = FeedPostFactory.build_batch( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", publication_date=timezone.now() - timedelta(hours=5), rule=rule, size=5, ) - last_post = PostFactory.build( + last_post = FeedPostFactory.build( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", publication_date=timezone.now() - timedelta(minutes=5), rule=rule, @@ -189,7 +189,7 @@ class FeedDuplicateHandlerTestCase(TestCase): def test_duplicate_entries_outside_time_slot(self): rule = FeedFactory() - existing_post = PostFactory( + existing_post = FeedPostFactory( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", title="Birmingham head teacher threatened over LGBT lessons", body="Google's move to end business ties with Huawei will affect current devices", @@ -198,7 +198,7 @@ class FeedDuplicateHandlerTestCase(TestCase): rule=rule, ) - new_posts = PostFactory.build_batch( + new_posts = FeedPostFactory.build_batch( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", title="Birmingham head teacher threatened over LGBT lessons", body="Google's move to end business ties with Huawei will affect current devices", @@ -207,7 +207,7 @@ class FeedDuplicateHandlerTestCase(TestCase): rule=rule, size=5, ) - last_post = PostFactory.build( + last_post = FeedPostFactory.build( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", title="Birmingham head teacher threatened over LGBT lessons", body="Google's move to end business ties with Huawei will affect current devices", @@ -235,14 +235,14 @@ class FeedDuplicateHandlerTestCase(TestCase): def test_duplicate_entries_in_collected_entries(self): rule = FeedFactory() - post_1 = PostFactory.build( + post_1 = FeedPostFactory.build( title="title got updated", body="body", url="https://bbc.com", publication_date=timezone.now(), rule=rule, ) - duplicate_post_1 = PostFactory.build( + duplicate_post_1 = FeedPostFactory.build( title="title got updated", body="body", url="https://bbc.com", @@ -250,11 +250,11 @@ class FeedDuplicateHandlerTestCase(TestCase): rule=rule, ) - post_2 = PostFactory.build( + post_2 = FeedPostFactory.build( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", publication_date=timezone.now(), ) - duplicate_post_2 = PostFactory.build( + duplicate_post_2 = FeedPostFactory.build( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", publication_date=timezone.now() - timedelta(minutes=5), ) diff --git a/src/newsreader/news/collection/tests/reddit/builder/mocks.py b/src/newsreader/news/collection/tests/reddit/builder/mocks.py index fabc802..625ced3 100644 --- a/src/newsreader/news/collection/tests/reddit/builder/mocks.py +++ b/src/newsreader/news/collection/tests/reddit/builder/mocks.py @@ -741,7 +741,7 @@ unsanitized_mock = { "author_flair_richtext": [], "gildings": {}, "content_categories": None, - "is_self": False, + "is_self": True, "mod_note": None, "crosspost_parent_list": [ { @@ -1709,3 +1709,2192 @@ duplicate_mock = { "before": None, }, } + +image_mock = { + "data": { + "after": "t3_hr3mhe", + "before": None, + "children": [ + { + "data": { + "all_awardings": [], + "allow_live_comments": True, + "approved_at_utc": None, + "approved_by": None, + "archived": False, + "author": "SamLynn79", + "author_flair_background_color": None, + "author_flair_css_class": None, + "author_flair_richtext": [], + "author_flair_template_id": None, + "author_flair_text": None, + "author_flair_text_color": None, + "author_flair_type": "text", + "author_fullname": "t2_6c9cj", + "author_patreon_flair": False, + "author_premium": True, + "awarders": [], + "banned_at_utc": None, + "banned_by": None, + "can_gild": True, + "can_mod_post": False, + "category": None, + "clicked": False, + "content_categories": None, + "contest_mode": False, + "created": 1594777552.0, + "created_utc": 1594748752.0, + "discussion_type": None, + "distinguished": None, + "domain": "i.redd.it", + "downs": 0, + "edited": False, + "gilded": 1, + "gildings": {"gid_2": 1}, + "hidden": False, + "hide_score": False, + "id": "hr64xh", + "is_crosspostable": True, + "is_meta": False, + "is_original_content": False, + "is_reddit_media_domain": True, + "is_robot_indexable": True, + "is_self": False, + "is_video": False, + "likes": None, + "link_flair_background_color": "", + "link_flair_css_class": None, + "link_flair_richtext": [], + "link_flair_text": None, + "link_flair_text_color": "dark", + "link_flair_type": "text", + "locked": True, + "media": None, + "media_embed": {}, + "media_only": False, + "mod_note": None, + "mod_reason_by": None, + "mod_reason_title": None, + "mod_reports": [], + "name": "t3_hr64xh", + "no_follow": False, + "num_comments": 579, + "num_crossposts": 2, + "num_reports": None, + "over_18": False, + "parent_whitelist_status": "all_ads", + "permalink": "/r/aww/comments/hr64xh/yall_i_just_cant_this_is_my_son_judah_my_wife_and/", + "pinned": False, + "post_hint": "image", + "preview": { + "enabled": True, + "images": [ + { + "id": "xWBh4hObZx0zmG_IDOHBLNN-_NZzEss2dAgm1sm9p1w", + "resolutions": [ + { + "height": 135, + "url": "https://preview.redd.it/cm2qybia1va51.jpg?width=108&crop=smart&auto=webp&s=5374b8f3dff520eba8cf97b589ebc67206f130dc", + "width": 108, + }, + { + "height": 270, + "url": "https://preview.redd.it/cm2qybia1va51.jpg?width=216&crop=smart&auto=webp&s=09d937a8db6f843d9fd34ee024cdfc6432dc0a13", + "width": 216, + }, + { + "height": 400, + "url": "https://preview.redd.it/cm2qybia1va51.jpg?width=320&crop=smart&auto=webp&s=9ba3654c12cb54f6d9c2dce1b07c80ecd6ca9d06", + "width": 320, + }, + { + "height": 800, + "url": "https://preview.redd.it/cm2qybia1va51.jpg?width=640&crop=smart&auto=webp&s=8c53747ae0f92b65fdd41f3aab60ebb8f8d4b1ca", + "width": 640, + }, + { + "height": 1200, + "url": "https://preview.redd.it/cm2qybia1va51.jpg?width=960&crop=smart&auto=webp&s=5668a626da6cd69e23b6c01587783c6cc5817bea", + "width": 960, + }, + { + "height": 1350, + "url": "https://preview.redd.it/cm2qybia1va51.jpg?width=1080&crop=smart&auto=webp&s=8fdd61aed8718109f3739cb532d96be31192b9a0", + "width": 1080, + }, + ], + "source": { + "height": 1800, + "url": "https://preview.redd.it/cm2qybia1va51.jpg?auto=webp&s=17b817b8d0e35bddc7f605d242cd7d116ef8e235", + "width": 1440, + }, + "variants": {}, + } + ], + }, + "pwls": 6, + "quarantine": False, + "removal_reason": None, + "removed_by": None, + "removed_by_category": None, + "report_reasons": None, + "saved": False, + "score": 23419, + "secure_media": None, + "secure_media_embed": {}, + "selftext": "", + "selftext_html": None, + "send_replies": True, + "spoiler": False, + "stickied": False, + "subreddit": "aww", + "subreddit_id": "t5_2qh1o", + "subreddit_name_prefixed": "r/aww", + "subreddit_subscribers": 25634399, + "subreddit_type": "public", + "suggested_sort": None, + "thumbnail": "https://b.thumbs.redditmedia.com/0X39S2jBL66zQCUbJAtlRKeswI8uUxf3-7vmog0VLjc.jpg", + "thumbnail_height": 140, + "thumbnail_width": 140, + "title": "Ya’ll, I just can’t... this is my " + "son, Judah. My wife and I have no " + "idea how we created such a " + "beautiful child.", + "top_awarded_type": None, + "total_awards_received": 4, + "treatment_tags": [], + "ups": 23419, + "upvote_ratio": 0.72, + "url": "https://i.redd.it/cm2qybia1va51.jpg", + "url_overridden_by_dest": "https://i.redd.it/cm2qybia1va51.jpg", + "user_reports": [], + "view_count": None, + "visited": False, + "whitelist_status": "all_ads", + "wls": 6, + }, + "kind": "t3", + }, + { + "data": { + "all_awardings": [], + "allow_live_comments": True, + "approved_at_utc": None, + "approved_by": None, + "archived": False, + "author": "0_GG_0", + "author_flair_background_color": None, + "author_flair_css_class": None, + "author_flair_richtext": [], + "author_flair_template_id": None, + "author_flair_text": None, + "author_flair_text_color": None, + "author_flair_type": "text", + "author_fullname": "t2_70k94sn8", + "author_patreon_flair": False, + "author_premium": True, + "awarders": [], + "banned_at_utc": None, + "banned_by": None, + "can_gild": True, + "can_mod_post": False, + "category": None, + "clicked": False, + "content_categories": None, + "contest_mode": False, + "created": 1594771808.0, + "created_utc": 1594743008.0, + "discussion_type": None, + "distinguished": None, + "domain": "i.redd.it", + "downs": 0, + "edited": False, + "gilded": 0, + "gildings": {}, + "hidden": False, + "hide_score": False, + "id": "hr4bxo", + "is_crosspostable": True, + "is_meta": False, + "is_original_content": False, + "is_reddit_media_domain": True, + "is_robot_indexable": True, + "is_self": False, + "is_video": False, + "likes": None, + "link_flair_background_color": "", + "link_flair_css_class": "lc", + "link_flair_richtext": [], + "link_flair_text": None, + "link_flair_text_color": "dark", + "link_flair_type": "text", + "locked": False, + "media": None, + "media_embed": {}, + "media_only": False, + "mod_note": None, + "mod_reason_by": None, + "mod_reason_title": None, + "mod_reports": [], + "name": "t3_hr4bxo", + "no_follow": False, + "num_comments": 248, + "num_crossposts": 4, + "num_reports": None, + "over_18": False, + "parent_whitelist_status": "all_ads", + "permalink": "/r/aww/comments/hr4bxo/just_thought_yall_would_enjoy_my_goat_dressed_as/", + "pinned": False, + "post_hint": "image", + "preview": { + "enabled": True, + "images": [ + { + "id": "TSXyc6ZJGdCcHk7-wuWnJdVpqsa_t8hmVd4k_e3ofCA", + "resolutions": [ + { + "height": 144, + "url": "https://preview.redd.it/4udujbu6kua51.jpg?width=108&crop=smart&auto=webp&s=ed5a11a7637acc66de48e30fd51d5019fa0c69f7", + "width": 108, + }, + { + "height": 288, + "url": "https://preview.redd.it/4udujbu6kua51.jpg?width=216&crop=smart&auto=webp&s=a812bec268d8ea31dbb9dfe696e0798490538f5a", + "width": 216, + }, + { + "height": 426, + "url": "https://preview.redd.it/4udujbu6kua51.jpg?width=320&crop=smart&auto=webp&s=1be4e3bdea19243b0a627bacb4c9e04f2d3569a7", + "width": 320, + }, + { + "height": 853, + "url": "https://preview.redd.it/4udujbu6kua51.jpg?width=640&crop=smart&auto=webp&s=e73755c3f0b27bb0435d07aa60b32e091bed7957", + "width": 640, + }, + { + "height": 1280, + "url": "https://preview.redd.it/4udujbu6kua51.jpg?width=960&crop=smart&auto=webp&s=8ab6972fffc4786503284a0253e91e9104f2d01e", + "width": 960, + }, + { + "height": 1440, + "url": "https://preview.redd.it/4udujbu6kua51.jpg?width=1080&crop=smart&auto=webp&s=a1e554889179a7599786985679304fda706d83d6", + "width": 1080, + }, + ], + "source": { + "height": 4032, + "url": "https://preview.redd.it/4udujbu6kua51.jpg?auto=webp&s=3eefdef653e0a3a8a10090b804f0888ee6a1a163", + "width": 3024, + }, + "variants": {}, + } + ], + }, + "pwls": 6, + "quarantine": False, + "removal_reason": None, + "removed_by": None, + "removed_by_category": None, + "report_reasons": None, + "saved": False, + "score": 16684, + "secure_media": None, + "secure_media_embed": {}, + "selftext": "", + "selftext_html": None, + "send_replies": True, + "spoiler": False, + "stickied": False, + "subreddit": "aww", + "subreddit_id": "t5_2qh1o", + "subreddit_name_prefixed": "r/aww", + "subreddit_subscribers": 25634399, + "subreddit_type": "public", + "suggested_sort": None, + "thumbnail": "https://b.thumbs.redditmedia.com/h3Ylp4kb0uJzAsST4ZZGsGN8WGxK4wjK2XrM9uUH5uc.jpg", + "thumbnail_height": 140, + "thumbnail_width": 140, + "title": "Just thought y’all would enjoy my " + "goat dressed as a tractor", + "top_awarded_type": None, + "total_awards_received": 2, + "treatment_tags": [], + "ups": 16684, + "upvote_ratio": 0.98, + "url": "https://i.redd.it/4udujbu6kua51.jpg", + "url_overridden_by_dest": "https://i.redd.it/4udujbu6kua51.jpg", + "user_reports": [], + "view_count": None, + "visited": False, + "whitelist_status": "all_ads", + "wls": 6, + }, + "kind": "t3", + }, + { + "data": { + "all_awardings": [], + "allow_live_comments": True, + "approved_at_utc": None, + "approved_by": None, + "archived": False, + "author": "Mechanic619", + "author_flair_background_color": None, + "author_flair_css_class": None, + "author_flair_richtext": [], + "author_flair_template_id": None, + "author_flair_text": None, + "author_flair_text_color": None, + "author_flair_type": "text", + "author_fullname": "t2_4ptrdtz5", + "author_patreon_flair": False, + "author_premium": False, + "awarders": [], + "banned_at_utc": None, + "banned_by": None, + "can_gild": True, + "can_mod_post": False, + "category": None, + "clicked": False, + "content_categories": None, + "contest_mode": False, + "created": 1594760700.0, + "created_utc": 1594731900.0, + "discussion_type": None, + "distinguished": None, + "domain": "i.redd.it", + "downs": 0, + "edited": False, + "gilded": 0, + "gildings": {"gid_1": 1}, + "hidden": False, + "hide_score": False, + "id": "hr14y5", + "is_crosspostable": True, + "is_meta": False, + "is_original_content": False, + "is_reddit_media_domain": True, + "is_robot_indexable": True, + "is_self": False, + "is_video": False, + "likes": None, + "link_flair_background_color": "", + "link_flair_css_class": "lc", + "link_flair_richtext": [], + "link_flair_text": None, + "link_flair_text_color": "dark", + "link_flair_type": "text", + "locked": False, + "media": None, + "media_embed": {}, + "media_only": False, + "mod_note": None, + "mod_reason_by": None, + "mod_reason_title": None, + "mod_reports": [], + "name": "t3_hr14y5", + "no_follow": False, + "num_comments": 1439, + "num_crossposts": 20, + "num_reports": None, + "over_18": False, + "parent_whitelist_status": "all_ads", + "permalink": "/r/aww/comments/hr14y5/mosque_security_on_patrol/", + "pinned": False, + "post_hint": "image", + "preview": { + "enabled": True, + "images": [ + { + "id": "Qs_FmhJgYT8GWyxmDQ8kjBCs_w2V_77cvHvdqLJ7i4s", + "resolutions": [ + { + "height": 135, + "url": "https://preview.redd.it/jk08ge66nta51.jpg?width=108&crop=smart&auto=webp&s=cf4c24ef4f9be86d186c143296bd1e14f15f960a", + "width": 108, + }, + { + "height": 270, + "url": "https://preview.redd.it/jk08ge66nta51.jpg?width=216&crop=smart&auto=webp&s=308e2367a849334c32b579265ed738d9937bed71", + "width": 216, + }, + { + "height": 400, + "url": "https://preview.redd.it/jk08ge66nta51.jpg?width=320&crop=smart&auto=webp&s=bc890f054dc34bb3f8607a70d088926afe113ff1", + "width": 320, + }, + { + "height": 800, + "url": "https://preview.redd.it/jk08ge66nta51.jpg?width=640&crop=smart&auto=webp&s=e23a9bc2d8d1ac6ccefab7f30cfa9def741aaa25", + "width": 640, + }, + { + "height": 1201, + "url": "https://preview.redd.it/jk08ge66nta51.jpg?width=960&crop=smart&auto=webp&s=4d294d1626046d27edc2a281c21ab10502b9ca4c", + "width": 960, + }, + { + "height": 1351, + "url": "https://preview.redd.it/jk08ge66nta51.jpg?width=1080&crop=smart&auto=webp&s=a801e5d9d703204e8b1497d3038d6405b2ed1157", + "width": 1080, + }, + ], + "source": { + "height": 1413, + "url": "https://preview.redd.it/jk08ge66nta51.jpg?auto=webp&s=f4e87e2ad0f0e40ca4f7a08c2a894b234601f3ce", + "width": 1129, + }, + "variants": {}, + } + ], + }, + "pwls": 6, + "quarantine": False, + "removal_reason": None, + "removed_by": None, + "removed_by_category": None, + "report_reasons": None, + "saved": False, + "score": 89133, + "secure_media": None, + "secure_media_embed": {}, + "selftext": "", + "selftext_html": None, + "send_replies": True, + "spoiler": False, + "stickied": False, + "subreddit": "aww", + "subreddit_id": "t5_2qh1o", + "subreddit_name_prefixed": "r/aww", + "subreddit_subscribers": 25634399, + "subreddit_type": "public", + "suggested_sort": None, + "thumbnail": "https://b.thumbs.redditmedia.com/GGHjIElMHDgefR0UdMXVk8CHeDUBhuZMY_QHjls4ynA.jpg", + "thumbnail_height": 140, + "thumbnail_width": 140, + "title": "Mosque security on patrol", + "top_awarded_type": None, + "total_awards_received": 3, + "treatment_tags": [], + "ups": 89133, + "upvote_ratio": 0.93, + "url": "https://i.redd.it/jk08ge66nta51.jpg", + "url_overridden_by_dest": "https://i.redd.it/jk08ge66nta51.jpg", + "user_reports": [], + "view_count": None, + "visited": False, + "whitelist_status": "all_ads", + "wls": 6, + }, + "kind": "t3", + }, + { + "data": { + "all_awardings": [], + "allow_live_comments": False, + "approved_at_utc": None, + "approved_by": None, + "archived": False, + "author": "Amnesia19", + "author_cakeday": True, + "author_flair_background_color": None, + "author_flair_css_class": None, + "author_flair_richtext": [], + "author_flair_template_id": None, + "author_flair_text": None, + "author_flair_text_color": None, + "author_flair_type": "text", + "author_fullname": "t2_1rqe7gk1", + "author_patreon_flair": False, + "author_premium": False, + "awarders": [], + "banned_at_utc": None, + "banned_by": None, + "can_gild": True, + "can_mod_post": False, + "category": None, + "clicked": False, + "content_categories": None, + "contest_mode": False, + "created": 1594765470.0, + "created_utc": 1594736670.0, + "discussion_type": None, + "distinguished": None, + "domain": "i.redd.it", + "downs": 0, + "edited": False, + "gilded": 0, + "gildings": {}, + "hidden": False, + "hide_score": False, + "id": "hr2fv0", + "is_crosspostable": True, + "is_meta": False, + "is_original_content": False, + "is_reddit_media_domain": True, + "is_robot_indexable": True, + "is_self": False, + "is_video": False, + "likes": None, + "link_flair_background_color": "", + "link_flair_css_class": None, + "link_flair_richtext": [], + "link_flair_text": None, + "link_flair_text_color": "dark", + "link_flair_type": "text", + "locked": False, + "media": None, + "media_embed": {}, + "media_only": False, + "mod_note": None, + "mod_reason_by": None, + "mod_reason_title": None, + "mod_reports": [], + "name": "t3_hr2fv0", + "no_follow": False, + "num_comments": 71, + "num_crossposts": 1, + "num_reports": None, + "over_18": False, + "parent_whitelist_status": "all_ads", + "permalink": "/r/aww/comments/hr2fv0/the_look_my_dog_gives_my_grandpa/", + "pinned": False, + "post_hint": "image", + "preview": { + "enabled": True, + "images": [ + { + "id": "v0BbkKy6haXmUxmHz4oXygoR0E-cHkvZDACWL_s7STw", + "resolutions": [ + { + "height": 144, + "url": "https://preview.redd.it/y6q7bgzc1ua51.jpg?width=108&crop=smart&auto=webp&s=4e65e8ff55c02de0ebe79763c91fe43f51216717", + "width": 108, + }, + { + "height": 288, + "url": "https://preview.redd.it/y6q7bgzc1ua51.jpg?width=216&crop=smart&auto=webp&s=e2006e5fe7ac43f911c17dc7f185f33db24e3b52", + "width": 216, + }, + { + "height": 426, + "url": "https://preview.redd.it/y6q7bgzc1ua51.jpg?width=320&crop=smart&auto=webp&s=3dad39d5e48a1b176f7e87b2dd110fb0044b32d7", + "width": 320, + }, + { + "height": 853, + "url": "https://preview.redd.it/y6q7bgzc1ua51.jpg?width=640&crop=smart&auto=webp&s=2f8e86a3feca27a23a72d10b92aba1b79b80f7be", + "width": 640, + }, + { + "height": 1280, + "url": "https://preview.redd.it/y6q7bgzc1ua51.jpg?width=960&crop=smart&auto=webp&s=5ecdd44b728031f8e109f41f99841a1d6c8e86c8", + "width": 960, + }, + { + "height": 1440, + "url": "https://preview.redd.it/y6q7bgzc1ua51.jpg?width=1080&crop=smart&auto=webp&s=49555499040c0ac9958dabd98cbe4e90c054b2a7", + "width": 1080, + }, + ], + "source": { + "height": 4032, + "url": "https://preview.redd.it/y6q7bgzc1ua51.jpg?auto=webp&s=443e98e46a8a096e426ebdc256c45682f46ebe2a", + "width": 3024, + }, + "variants": {}, + } + ], + }, + "pwls": 6, + "quarantine": False, + "removal_reason": None, + "removed_by": None, + "removed_by_category": None, + "report_reasons": None, + "saved": False, + "score": 13614, + "secure_media": None, + "secure_media_embed": {}, + "selftext": "", + "selftext_html": None, + "send_replies": True, + "spoiler": False, + "stickied": False, + "subreddit": "aww", + "subreddit_id": "t5_2qh1o", + "subreddit_name_prefixed": "r/aww", + "subreddit_subscribers": 25634399, + "subreddit_type": "public", + "suggested_sort": None, + "thumbnail": "https://b.thumbs.redditmedia.com/RWRuGJ7ZyBtjO6alY1vbc65TQzgng8RFRWnPG7WUkhE.jpg", + "thumbnail_height": 140, + "thumbnail_width": 140, + "title": "The look my dog gives my grandpa", + "top_awarded_type": None, + "total_awards_received": 0, + "treatment_tags": [], + "ups": 13614, + "upvote_ratio": 0.99, + "url": "https://i.redd.it/y6q7bgzc1ua51.jpg", + "url_overridden_by_dest": "https://i.redd.it/y6q7bgzc1ua51.jpg", + "user_reports": [], + "view_count": None, + "visited": False, + "whitelist_status": "all_ads", + "wls": 6, + }, + "kind": "t3", + }, + ], + "dist": 25, + "modhash": None, + }, + "kind": "Listing", +} + +external_image_mock = { + "data": { + "after": "t3_hr3mhe", + "before": None, + "children": [ + { + "data": { + "all_awardings": [], + "allow_live_comments": False, + "approved_at_utc": None, + "approved_by": None, + "archived": False, + "author": "Captainbuttsreads", + "author_flair_background_color": None, + "author_flair_css_class": None, + "author_flair_richtext": [], + "author_flair_template_id": None, + "author_flair_text": None, + "author_flair_text_color": None, + "author_flair_type": "text", + "author_fullname": "t2_5qaat4af", + "author_patreon_flair": False, + "author_premium": False, + "awarders": [], + "banned_at_utc": None, + "banned_by": None, + "can_gild": True, + "can_mod_post": False, + "category": None, + "clicked": False, + "content_categories": None, + "contest_mode": False, + "created": 1594770844.0, + "created_utc": 1594742044.0, + "crosspost_parent": "t3_gc6eq2", + "crosspost_parent_list": [], + "discussion_type": None, + "distinguished": None, + "domain": "gfycat.com", + "downs": 0, + "edited": False, + "gilded": 0, + "gildings": {}, + "hidden": False, + "hide_score": False, + "id": "hr41am", + "is_crosspostable": True, + "is_meta": False, + "is_original_content": False, + "is_reddit_media_domain": False, + "is_robot_indexable": True, + "is_self": False, + "is_video": False, + "likes": None, + "link_flair_background_color": "", + "link_flair_css_class": None, + "link_flair_richtext": [], + "link_flair_text": None, + "link_flair_text_color": "dark", + "link_flair_type": "text", + "locked": False, + "media": None, + "media_embed": {}, + "media_only": False, + "mod_note": None, + "mod_reason_by": None, + "mod_reason_title": None, + "mod_reports": [], + "name": "t3_hr41am", + "no_follow": False, + "num_comments": 45, + "num_crossposts": 0, + "num_reports": None, + "over_18": False, + "parent_whitelist_status": "all_ads", + "permalink": "/r/aww/comments/hr41am/excited_cows_have_a_new_brush/", + "pinned": False, + "post_hint": "link", + "preview": { + "enabled": False, + "images": [ + { + "id": "l5tVSe6B4QDc7wk6Z9WfCXr20D_rAOHerf6i0N53nNc", + "resolutions": [ + { + "height": 108, + "url": "https://external-preview.redd.it/R51JAzaGbva91vYxn9uWL3NwCzWJW5mrdVxb1idjtBg.jpg?width=108&crop=smart&auto=webp&s=f908e1fb9403194a31f9a0c1f056f59e0718201e", + "width": 108, + }, + { + "height": 216, + "url": "https://external-preview.redd.it/R51JAzaGbva91vYxn9uWL3NwCzWJW5mrdVxb1idjtBg.jpg?width=216&crop=smart&auto=webp&s=de377df68832a52419d83c06ea74a13de28b96e0", + "width": 216, + }, + ], + "source": { + "height": 250, + "url": "https://external-preview.redd.it/R51JAzaGbva91vYxn9uWL3NwCzWJW5mrdVxb1idjtBg.jpg?auto=webp&s=b4166cb5a350e6d0197381cdf8db702f8a760493", + "width": 250, + }, + "variants": {}, + } + ], + "reddit_video_preview": { + "dash_url": "https://v.redd.it/mimyo7z6ppa51/DASHPlaylist.mpd", + "duration": 33, + "fallback_url": "https://v.redd.it/mimyo7z6ppa51/DASH_480.mp4", + "height": 640, + "hls_url": "https://v.redd.it/mimyo7z6ppa51/HLSPlaylist.m3u8", + "is_gif": True, + "scrubber_media_url": "https://v.redd.it/mimyo7z6ppa51/DASH_96.mp4", + "transcoding_status": "completed", + "width": 640, + }, + }, + "pwls": 6, + "quarantine": False, + "removal_reason": None, + "removed_by": None, + "removed_by_category": None, + "report_reasons": None, + "saved": False, + "score": 3219, + "secure_media": None, + "secure_media_embed": {}, + "selftext": "", + "selftext_html": None, + "send_replies": False, + "spoiler": False, + "stickied": False, + "subreddit": "aww", + "subreddit_id": "t5_2qh1o", + "subreddit_name_prefixed": "r/aww", + "subreddit_subscribers": 25634399, + "subreddit_type": "public", + "suggested_sort": None, + "thumbnail": "https://b.thumbs.redditmedia.com/NKTwvIU2xxoOMpzYNlYYstS2586x64Gi--52N0M-OJY.jpg", + "thumbnail_height": 140, + "thumbnail_width": 140, + "title": "Excited cows have a new brush!", + "top_awarded_type": None, + "total_awards_received": 0, + "treatment_tags": [], + "ups": 3219, + "upvote_ratio": 0.99, + "url": "http://gfycat.com/thatalivedogwoodclubgall", + "url_overridden_by_dest": "http://gfycat.com/thatalivedogwoodclubgall", + "user_reports": [], + "view_count": None, + "visited": False, + "whitelist_status": "all_ads", + "wls": 6, + }, + "kind": "t3", + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "aww", + "selftext": "", + "author_fullname": "t2_78ni2", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Novosibirsk Zoo welcomes 16 cobalt-eyed Pallas’s cat kittens", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/aww", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "thumbnail_height": 93, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_huoldn", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.99, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 1933, + "total_awards_received": 0, + "media_embed": {}, + "thumbnail_width": 140, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 1933, + "approved_by": None, + "author_premium": False, + "thumbnail": "https://a.thumbs.redditmedia.com/j-D-Z79QQ6tGk0E3SGdb8GzqbLVUY3lu59tDaXbOYl8.jpg", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "post_hint": "image", + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1595292144, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.imgur.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.imgur.com/usfMVUJ.jpg", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": False, + "pinned": False, + "over_18": False, + "preview": { + "images": [ + { + "source": { + "url": "https://external-preview.redd.it/iPfDCL-dfB4_TmnGsMEXWJMzxXmBEMPuV5ejLkYBNVo.jpg?auto=webp&s=2126d34a0134efa94ecab03917944709c8bc3305", + "width": 1024, + "height": 682, + }, + "resolutions": [ + { + "url": "https://external-preview.redd.it/iPfDCL-dfB4_TmnGsMEXWJMzxXmBEMPuV5ejLkYBNVo.jpg?width=108&crop=smart&auto=webp&s=710a44f787b98a0a37ca543b7428917ee55b3c46", + "width": 108, + "height": 71, + }, + { + "url": "https://external-preview.redd.it/iPfDCL-dfB4_TmnGsMEXWJMzxXmBEMPuV5ejLkYBNVo.jpg?width=216&crop=smart&auto=webp&s=b1bcdd7734a3a569f99fa88c6be9447105e58276", + "width": 216, + "height": 143, + }, + { + "url": "https://external-preview.redd.it/iPfDCL-dfB4_TmnGsMEXWJMzxXmBEMPuV5ejLkYBNVo.jpg?width=320&crop=smart&auto=webp&s=1671bf09a7b73d0ca51cf2de884b37d6a3591d6a", + "width": 320, + "height": 213, + }, + { + "url": "https://external-preview.redd.it/iPfDCL-dfB4_TmnGsMEXWJMzxXmBEMPuV5ejLkYBNVo.jpg?width=640&crop=smart&auto=webp&s=9fcdddbaeaad13273e0b53a862c73c4fee9f7e3d", + "width": 640, + "height": 426, + }, + { + "url": "https://external-preview.redd.it/iPfDCL-dfB4_TmnGsMEXWJMzxXmBEMPuV5ejLkYBNVo.jpg?width=960&crop=smart&auto=webp&s=e531480236c0ae72b78f27dd88f2cedc9f73cccc", + "width": 960, + "height": 639, + }, + ], + "variants": {}, + "id": "oJ9pHVA-JhoodtgNlku8ZQv8FhtadS2r36wGLAriUtY", + } + ], + "enabled": True, + }, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": False, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1o", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "huoldn", + "is_robot_indexable": True, + "report_reasons": None, + "author": "Ben_zyl", + "discussion_type": None, + "num_comments": 20, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/aww/comments/huoldn/novosibirsk_zoo_welcomes_16_cobalteyed_pallass/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.imgur.com/usfMVUJ.jpg", + "subreddit_subscribers": 25723833, + "created_utc": 1595263344, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + ], + "dist": 25, + "modhash": None, + }, + "kind": "Listing", +} + +video_mock = { + "data": { + "after": "t3_hr3mhe", + "before": None, + "children": [ + { + "data": { + "all_awardings": [], + "allow_live_comments": False, + "approved_at_utc": None, + "approved_by": None, + "archived": False, + "author": "TommyLondoner", + "author_flair_background_color": None, + "author_flair_css_class": None, + "author_flair_richtext": [], + "author_flair_template_id": None, + "author_flair_text": None, + "author_flair_text_color": None, + "author_flair_type": "text", + "author_fullname": "t2_75bis9gi", + "author_patreon_flair": False, + "author_premium": True, + "awarders": [], + "banned_at_utc": None, + "banned_by": None, + "can_gild": True, + "can_mod_post": False, + "category": None, + "clicked": False, + "content_categories": None, + "contest_mode": False, + "created": 1594767660.0, + "created_utc": 1594738860.0, + "discussion_type": None, + "distinguished": None, + "domain": "v.redd.it", + "downs": 0, + "edited": False, + "gilded": 1, + "gildings": {"gid_2": 1}, + "hidden": False, + "hide_score": False, + "id": "hr32jf", + "is_crosspostable": True, + "is_meta": False, + "is_original_content": False, + "is_reddit_media_domain": True, + "is_robot_indexable": True, + "is_self": False, + "is_video": True, + "likes": None, + "link_flair_background_color": "", + "link_flair_css_class": "lc", + "link_flair_richtext": [], + "link_flair_text": None, + "link_flair_text_color": "dark", + "link_flair_type": "text", + "locked": False, + "media": { + "reddit_video": { + "dash_url": "https://v.redd.it/9avhmd5s7ua51/DASHPlaylist.mpd?a=1597351258%2CODVjMjcyMDkzOWE1NDBiNzUwNzVhNDUwYmE0MGNiNzk5MGRmZmZmMzBhZjIzNDAzYzczY2NkNzRjNTgyMjAzNQ%3D%3D&v=1&f=sd", + "duration": 78, + "fallback_url": "https://v.redd.it/9avhmd5s7ua51/DASH_360.mp4?source=fallback", + "height": 428, + "hls_url": "https://v.redd.it/9avhmd5s7ua51/HLSPlaylist.m3u8?a=1597351258%2CNjE4YTA0NjUwZWNmNjhjNTRhNmU4ZjBmNDMyYWYxOGYzZTNkZWM2YjViM2I2ZDZjZWNhYzY0ZGVmOWU0Y2EyYg%3D%3D&v=1&f=sd", + "is_gif": False, + "scrubber_media_url": "https://v.redd.it/9avhmd5s7ua51/DASH_96.mp4", + "transcoding_status": "completed", + "width": 258, + } + }, + "media_embed": {}, + "media_only": False, + "mod_note": None, + "mod_reason_by": None, + "mod_reason_title": None, + "mod_reports": [], + "name": "t3_hr32jf", + "no_follow": False, + "num_comments": 150, + "num_crossposts": 2, + "num_reports": None, + "over_18": False, + "parent_whitelist_status": "all_ads", + "permalink": "/r/aww/comments/hr32jf/this_guy_definitely_loves_his_job/", + "pinned": False, + "post_hint": "hosted:video", + "preview": { + "enabled": False, + "images": [ + { + "id": "dX_mx_ZfJMwVn_pak9ZPQq8rMT_gPkW0_4gOzDxPSHM", + "resolutions": [ + { + "height": 179, + "url": "https://external-preview.redd.it/PMy-Z___DIG6aWnoyEy1VKottxLQFWCRSdHDV1a9N8w.png?width=108&crop=smart&format=pjpg&auto=webp&s=e0b8b68a78a8e9071bf56417ac6589bc8aff7634", + "width": 108, + }, + { + "height": 358, + "url": "https://external-preview.redd.it/PMy-Z___DIG6aWnoyEy1VKottxLQFWCRSdHDV1a9N8w.png?width=216&crop=smart&format=pjpg&auto=webp&s=8668c3c7ccbdacfe3376d8af4b1b49df9d6aec97", + "width": 216, + }, + ], + "source": { + "height": 428, + "url": "https://external-preview.redd.it/PMy-Z___DIG6aWnoyEy1VKottxLQFWCRSdHDV1a9N8w.png?format=pjpg&auto=webp&s=b0b6439fbe01c3f5d1bf1eae54a588cc745d3415", + "width": 258, + }, + "variants": {}, + } + ], + }, + "pwls": 6, + "quarantine": False, + "removal_reason": None, + "removed_by": None, + "removed_by_category": None, + "report_reasons": None, + "saved": False, + "score": 9324, + "secure_media": { + "reddit_video": { + "dash_url": "https://v.redd.it/9avhmd5s7ua51/DASHPlaylist.mpd?a=1597351258%2CODVjMjcyMDkzOWE1NDBiNzUwNzVhNDUwYmE0MGNiNzk5MGRmZmZmMzBhZjIzNDAzYzczY2NkNzRjNTgyMjAzNQ%3D%3D&v=1&f=sd", + "duration": 78, + "fallback_url": "https://v.redd.it/9avhmd5s7ua51/DASH_360.mp4?source=fallback", + "height": 428, + "hls_url": "https://v.redd.it/9avhmd5s7ua51/HLSPlaylist.m3u8?a=1597351258%2CNjE4YTA0NjUwZWNmNjhjNTRhNmU4ZjBmNDMyYWYxOGYzZTNkZWM2YjViM2I2ZDZjZWNhYzY0ZGVmOWU0Y2EyYg%3D%3D&v=1&f=sd", + "is_gif": False, + "scrubber_media_url": "https://v.redd.it/9avhmd5s7ua51/DASH_96.mp4", + "transcoding_status": "completed", + "width": 258, + } + }, + "secure_media_embed": {}, + "selftext": "", + "selftext_html": None, + "send_replies": True, + "spoiler": False, + "stickied": False, + "subreddit": "aww", + "subreddit_id": "t5_2qh1o", + "subreddit_name_prefixed": "r/aww", + "subreddit_subscribers": 25634399, + "subreddit_type": "public", + "suggested_sort": None, + "thumbnail": "https://b.thumbs.redditmedia.com/ibsS3H5xMLDSVglh8NBYJ4cgIsXuqYVLJWbiYVTykXg.jpg", + "thumbnail_height": 140, + "thumbnail_width": 140, + "title": "This guy definitely loves his job !", + "top_awarded_type": None, + "total_awards_received": 1, + "treatment_tags": [], + "ups": 9324, + "upvote_ratio": 0.96, + "url": "https://v.redd.it/9avhmd5s7ua51", + "url_overridden_by_dest": "https://v.redd.it/9avhmd5s7ua51", + "user_reports": [], + "view_count": None, + "visited": False, + "whitelist_status": "all_ads", + "wls": 6, + }, + "kind": "t3", + }, + { + "data": { + "all_awardings": [], + "allow_live_comments": True, + "approved_at_utc": None, + "approved_by": None, + "archived": False, + "author": "LucileEsparza", + "author_flair_background_color": None, + "author_flair_css_class": None, + "author_flair_richtext": [], + "author_flair_template_id": None, + "author_flair_text": None, + "author_flair_text_color": None, + "author_flair_type": "text", + "author_fullname": "t2_5loa1v96", + "author_patreon_flair": False, + "author_premium": False, + "awarders": [], + "banned_at_utc": None, + "banned_by": None, + "can_gild": True, + "can_mod_post": False, + "category": None, + "clicked": False, + "content_categories": None, + "contest_mode": False, + "created": 1594762969.0, + "created_utc": 1594734169.0, + "discussion_type": None, + "distinguished": None, + "domain": "v.redd.it", + "downs": 0, + "edited": False, + "gilded": 0, + "gildings": {}, + "hidden": False, + "hide_score": False, + "id": "hr1r00", + "is_crosspostable": True, + "is_meta": False, + "is_original_content": False, + "is_reddit_media_domain": True, + "is_robot_indexable": True, + "is_self": False, + "is_video": True, + "likes": None, + "link_flair_background_color": "", + "link_flair_css_class": "lc", + "link_flair_richtext": [], + "link_flair_text": None, + "link_flair_text_color": "dark", + "link_flair_type": "text", + "locked": False, + "media": { + "reddit_video": { + "dash_url": "https://v.redd.it/eyvbxaeqtta51/DASHPlaylist.mpd?a=1597351258%2CYjJmMWE3ZGJmM2FhMzVkYzZlNjIzOTAwM2ZmZTBkYjAxMzE0NDY2MDIyNGRhOWViMTViZTE0NTlmMzkzM2JlYg%3D%3D&v=1&f=sd", + "duration": 8, + "fallback_url": "https://v.redd.it/eyvbxaeqtta51/DASH_480.mp4?source=fallback", + "height": 640, + "hls_url": "https://v.redd.it/eyvbxaeqtta51/HLSPlaylist.m3u8?a=1597351258%2CY2JiMmQ0MjliNmE5NTA5MDE3YjAyNmVkYTg2Yjg1YWYwYmJlNDE4ZGM1NjE4ZDU3YjkzYjJlMDE2ZmM4Yzk5MQ%3D%3D&v=1&f=sd", + "is_gif": False, + "scrubber_media_url": "https://v.redd.it/eyvbxaeqtta51/DASH_96.mp4", + "transcoding_status": "completed", + "width": 640, + } + }, + "media_embed": {}, + "media_only": False, + "mod_note": None, + "mod_reason_by": None, + "mod_reason_title": None, + "mod_reports": [], + "name": "t3_hr1r00", + "no_follow": False, + "num_comments": 63, + "num_crossposts": 3, + "num_reports": None, + "over_18": False, + "parent_whitelist_status": "all_ads", + "permalink": "/r/aww/comments/hr1r00/cool_catt_and_his_clingy_girlfriend/", + "pinned": False, + "post_hint": "hosted:video", + "preview": { + "enabled": False, + "images": [ + { + "id": "wrscJ_l9A6Q_Mn1NAg06I4o3W39bbNgTBYg2Xm_Vl8U", + "resolutions": [ + { + "height": 108, + "url": "https://external-preview.redd.it/GkeXMqmDn03NOYgnIk0QDQAS8HfRacPrmYvyTUSv6w0.png?width=108&crop=smart&format=pjpg&auto=webp&s=f285ef95065be8a340e1cb7792d80a9640564eb6", + "width": 108, + }, + { + "height": 216, + "url": "https://external-preview.redd.it/GkeXMqmDn03NOYgnIk0QDQAS8HfRacPrmYvyTUSv6w0.png?width=216&crop=smart&format=pjpg&auto=webp&s=6d26b4f8d7b16f0f02bc6ce6f35af889b43cf026", + "width": 216, + }, + { + "height": 320, + "url": "https://external-preview.redd.it/GkeXMqmDn03NOYgnIk0QDQAS8HfRacPrmYvyTUSv6w0.png?width=320&crop=smart&format=pjpg&auto=webp&s=5d081467da187bd8c24e9c524583513ee6afe388", + "width": 320, + }, + { + "height": 640, + "url": "https://external-preview.redd.it/GkeXMqmDn03NOYgnIk0QDQAS8HfRacPrmYvyTUSv6w0.png?width=640&crop=smart&format=pjpg&auto=webp&s=557369f302f18b35284ffaacaccf09986f755187", + "width": 640, + }, + ], + "source": { + "height": 640, + "url": "https://external-preview.redd.it/GkeXMqmDn03NOYgnIk0QDQAS8HfRacPrmYvyTUSv6w0.png?format=pjpg&auto=webp&s=cb0a79a2effe0323e862fb713dab76b39051afbb", + "width": 640, + }, + "variants": {}, + } + ], + }, + "pwls": 6, + "quarantine": False, + "removal_reason": None, + "removed_by": None, + "removed_by_category": None, + "report_reasons": None, + "saved": False, + "score": 11007, + "secure_media": { + "reddit_video": { + "dash_url": "https://v.redd.it/eyvbxaeqtta51/DASHPlaylist.mpd?a=1597351258%2CYjJmMWE3ZGJmM2FhMzVkYzZlNjIzOTAwM2ZmZTBkYjAxMzE0NDY2MDIyNGRhOWViMTViZTE0NTlmMzkzM2JlYg%3D%3D&v=1&f=sd", + "duration": 8, + "fallback_url": "https://v.redd.it/eyvbxaeqtta51/DASH_480.mp4?source=fallback", + "height": 640, + "hls_url": "https://v.redd.it/eyvbxaeqtta51/HLSPlaylist.m3u8?a=1597351258%2CY2JiMmQ0MjliNmE5NTA5MDE3YjAyNmVkYTg2Yjg1YWYwYmJlNDE4ZGM1NjE4ZDU3YjkzYjJlMDE2ZmM4Yzk5MQ%3D%3D&v=1&f=sd", + "is_gif": False, + "scrubber_media_url": "https://v.redd.it/eyvbxaeqtta51/DASH_96.mp4", + "transcoding_status": "completed", + "width": 640, + } + }, + "secure_media_embed": {}, + "selftext": "", + "selftext_html": None, + "send_replies": False, + "spoiler": False, + "stickied": False, + "subreddit": "aww", + "subreddit_id": "t5_2qh1o", + "subreddit_name_prefixed": "r/aww", + "subreddit_subscribers": 25634399, + "subreddit_type": "public", + "suggested_sort": None, + "thumbnail": "https://b.thumbs.redditmedia.com/WSBiDcoWPwAgSkt08uCI6TK7v_tdAdHmQHv7TePyTOs.jpg", + "thumbnail_height": 140, + "thumbnail_width": 140, + "title": "Cool catt and his clingy girlfriend", + "top_awarded_type": None, + "total_awards_received": 1, + "treatment_tags": [], + "ups": 11007, + "upvote_ratio": 0.99, + "url": "https://v.redd.it/eyvbxaeqtta51", + "url_overridden_by_dest": "https://v.redd.it/eyvbxaeqtta51", + "user_reports": [], + "view_count": None, + "visited": False, + "whitelist_status": "all_ads", + "wls": 6, + }, + "kind": "t3", + }, + { + "data": { + "all_awardings": [], + "allow_live_comments": False, + "approved_at_utc": None, + "approved_by": None, + "archived": False, + "author": "memezzer", + "author_flair_background_color": "", + "author_flair_css_class": "k", + "author_flair_richtext": [], + "author_flair_template_id": None, + "author_flair_text": None, + "author_flair_text_color": "dark", + "author_flair_type": "text", + "author_fullname": "t2_41jaebm4", + "author_patreon_flair": False, + "author_premium": True, + "awarders": [], + "banned_at_utc": None, + "banned_by": None, + "can_gild": True, + "can_mod_post": False, + "category": None, + "clicked": False, + "content_categories": None, + "contest_mode": False, + "created": 1594759625.0, + "created_utc": 1594730825.0, + "discussion_type": None, + "distinguished": None, + "domain": "v.redd.it", + "downs": 0, + "edited": False, + "gilded": 0, + "gildings": {}, + "hidden": False, + "hide_score": False, + "id": "hr0uzh", + "is_crosspostable": True, + "is_meta": False, + "is_original_content": False, + "is_reddit_media_domain": True, + "is_robot_indexable": True, + "is_self": False, + "is_video": True, + "likes": None, + "link_flair_background_color": "", + "link_flair_css_class": None, + "link_flair_richtext": [], + "link_flair_text": None, + "link_flair_text_color": "dark", + "link_flair_type": "text", + "locked": False, + "media": { + "reddit_video": { + "dash_url": "https://v.redd.it/y0mavwswjta51/DASHPlaylist.mpd?a=1597351258%2CYjU1NzFjOTE0YzY2OTdmODk3MGRiMGU4MjdhOGE5ODk2YWNiODQyMGUyOWRhNzI1M2U1MTEyZjBhOWZkZTZmMw%3D%3D&v=1&f=sd", + "duration": 8, + "fallback_url": "https://v.redd.it/y0mavwswjta51/DASH_720.mp4?source=fallback", + "height": 960, + "hls_url": "https://v.redd.it/y0mavwswjta51/HLSPlaylist.m3u8?a=1597351258%2CODk4NTdhMzA3NmY2ZmY2NGQxMmI2ZjcyMzk0ZTFhOTdhOGI4NGQ1NjBiMzNiMmVmZDBhMTQ4MGRkOWJlOWU1YQ%3D%3D&v=1&f=sd", + "is_gif": False, + "scrubber_media_url": "https://v.redd.it/y0mavwswjta51/DASH_96.mp4", + "transcoding_status": "completed", + "width": 960, + } + }, + "media_embed": {}, + "media_only": False, + "mod_note": None, + "mod_reason_by": None, + "mod_reason_title": None, + "mod_reports": [], + "name": "t3_hr0uzh", + "no_follow": False, + "num_comments": 86, + "num_crossposts": 3, + "num_reports": None, + "over_18": False, + "parent_whitelist_status": "all_ads", + "permalink": "/r/aww/comments/hr0uzh/good_pillow/", + "pinned": False, + "post_hint": "hosted:video", + "preview": { + "enabled": False, + "images": [ + { + "id": "neoTdGv5lMArlfu6euGUK_v_O87Lfmdrrz1ePTwzp1w", + "resolutions": [ + { + "height": 108, + "url": "https://external-preview.redd.it/E3alyJV8xErhjjDt_Qujcd0ZqmUhGOSFINXALm2FZAQ.png?width=108&crop=smart&format=pjpg&auto=webp&s=dcc1172b7ace007e8c72080519a16a487596d7e2", + "width": 108, + }, + { + "height": 216, + "url": "https://external-preview.redd.it/E3alyJV8xErhjjDt_Qujcd0ZqmUhGOSFINXALm2FZAQ.png?width=216&crop=smart&format=pjpg&auto=webp&s=a7968ce1aa34957a7f7103d06a66d4f9df95d437", + "width": 216, + }, + { + "height": 320, + "url": "https://external-preview.redd.it/E3alyJV8xErhjjDt_Qujcd0ZqmUhGOSFINXALm2FZAQ.png?width=320&crop=smart&format=pjpg&auto=webp&s=a2302d80948fba08e91db0a10db579341e1df712", + "width": 320, + }, + { + "height": 640, + "url": "https://external-preview.redd.it/E3alyJV8xErhjjDt_Qujcd0ZqmUhGOSFINXALm2FZAQ.png?width=640&crop=smart&format=pjpg&auto=webp&s=a8487450d38d14bcdfda2aeb659b453d8b1cacab", + "width": 640, + }, + { + "height": 960, + "url": "https://external-preview.redd.it/E3alyJV8xErhjjDt_Qujcd0ZqmUhGOSFINXALm2FZAQ.png?width=960&crop=smart&format=pjpg&auto=webp&s=d371bee68cab49130babe4b890c6323db128c214", + "width": 960, + }, + ], + "source": { + "height": 960, + "url": "https://external-preview.redd.it/E3alyJV8xErhjjDt_Qujcd0ZqmUhGOSFINXALm2FZAQ.png?format=pjpg&auto=webp&s=ff90de8f0a693afeca69dc85dbecb6af9783c769", + "width": 960, + }, + "variants": {}, + } + ], + }, + "pwls": 6, + "quarantine": False, + "removal_reason": None, + "removed_by": None, + "removed_by_category": None, + "report_reasons": None, + "saved": False, + "score": 13271, + "secure_media": { + "reddit_video": { + "dash_url": "https://v.redd.it/y0mavwswjta51/DASHPlaylist.mpd?a=1597351258%2CYjU1NzFjOTE0YzY2OTdmODk3MGRiMGU4MjdhOGE5ODk2YWNiODQyMGUyOWRhNzI1M2U1MTEyZjBhOWZkZTZmMw%3D%3D&v=1&f=sd", + "duration": 8, + "fallback_url": "https://v.redd.it/y0mavwswjta51/DASH_720.mp4?source=fallback", + "height": 960, + "hls_url": "https://v.redd.it/y0mavwswjta51/HLSPlaylist.m3u8?a=1597351258%2CODk4NTdhMzA3NmY2ZmY2NGQxMmI2ZjcyMzk0ZTFhOTdhOGI4NGQ1NjBiMzNiMmVmZDBhMTQ4MGRkOWJlOWU1YQ%3D%3D&v=1&f=sd", + "is_gif": False, + "scrubber_media_url": "https://v.redd.it/y0mavwswjta51/DASH_96.mp4", + "transcoding_status": "completed", + "width": 960, + } + }, + "secure_media_embed": {}, + "selftext": "", + "selftext_html": None, + "send_replies": True, + "spoiler": False, + "stickied": False, + "subreddit": "aww", + "subreddit_id": "t5_2qh1o", + "subreddit_name_prefixed": "r/aww", + "subreddit_subscribers": 25634399, + "subreddit_type": "public", + "suggested_sort": "confidence", + "thumbnail": "https://b.thumbs.redditmedia.com/sxFESWCVsSf4ij5_-a1xdJaFhSU2MjJ5T_TVFbook6Q.jpg", + "thumbnail_height": 140, + "thumbnail_width": 140, + "title": "Good pillow", + "top_awarded_type": None, + "total_awards_received": 0, + "treatment_tags": [], + "ups": 13271, + "upvote_ratio": 0.99, + "url": "https://v.redd.it/y0mavwswjta51", + "url_overridden_by_dest": "https://v.redd.it/y0mavwswjta51", + "user_reports": [], + "view_count": None, + "visited": False, + "whitelist_status": "all_ads", + "wls": 6, + }, + "kind": "t3", + }, + { + "data": { + "all_awardings": [], + "allow_live_comments": True, + "approved_at_utc": None, + "approved_by": None, + "archived": False, + "author": "asdfpartyy", + "author_flair_background_color": None, + "author_flair_css_class": None, + "author_flair_richtext": [], + "author_flair_template_id": None, + "author_flair_text": None, + "author_flair_text_color": None, + "author_flair_type": "text", + "author_fullname": "t2_t0ay0", + "author_patreon_flair": False, + "author_premium": True, + "awarders": [], + "banned_at_utc": None, + "banned_by": None, + "can_gild": True, + "can_mod_post": False, + "category": None, + "clicked": False, + "content_categories": None, + "contest_mode": False, + "created": 1594745472.0, + "created_utc": 1594716672.0, + "discussion_type": None, + "distinguished": None, + "domain": "v.redd.it", + "downs": 0, + "edited": False, + "gilded": 1, + "gildings": {"gid_2": 1}, + "hidden": False, + "hide_score": False, + "id": "hqy0ny", + "is_crosspostable": True, + "is_meta": False, + "is_original_content": False, + "is_reddit_media_domain": True, + "is_robot_indexable": True, + "is_self": False, + "is_video": True, + "likes": None, + "link_flair_background_color": "", + "link_flair_css_class": None, + "link_flair_richtext": [], + "link_flair_text": None, + "link_flair_text_color": "dark", + "link_flair_type": "text", + "locked": False, + "media": { + "reddit_video": { + "dash_url": "https://v.redd.it/asj4p03rdsa51/DASHPlaylist.mpd?a=1597351258%2CY2VmYTAyMWNmZjIwZjQ4YTBmMDc5MTRjOTU0NjliZWU3MDE2YTU3NjJiYzQxZWRiODY4ZTc1YWI1NDY4MWIxNA%3D%3D&v=1&f=sd", + "duration": 30, + "fallback_url": "https://v.redd.it/asj4p03rdsa51/DASH_360.mp4?source=fallback", + "height": 360, + "hls_url": "https://v.redd.it/asj4p03rdsa51/HLSPlaylist.m3u8?a=1597351258%2CY2QxM2I4Njk5MmIyOTRiZTBhNDQ2MDg0ZTM2NTllYzBjODBlYjNiNDc1Mzg2ODIxNDk4MTAzMzYyNzlmNjI1NQ%3D%3D&v=1&f=sd", + "is_gif": False, + "scrubber_media_url": "https://v.redd.it/asj4p03rdsa51/DASH_96.mp4", + "transcoding_status": "completed", + "width": 640, + } + }, + "media_embed": {}, + "media_only": False, + "mod_note": None, + "mod_reason_by": None, + "mod_reason_title": None, + "mod_reports": [], + "name": "t3_hqy0ny", + "no_follow": False, + "num_comments": 849, + "num_crossposts": 24, + "num_reports": None, + "over_18": False, + "parent_whitelist_status": "all_ads", + "permalink": "/r/aww/comments/hqy0ny/bunnies_flop_over_when_they_feel_completely_safe/", + "pinned": False, + "post_hint": "hosted:video", + "preview": { + "enabled": False, + "images": [ + { + "id": "eMi5JzdWDMeDALsqK8bVceX3jbXTWS_S1D-Ie1hQxnc", + "resolutions": [ + { + "height": 60, + "url": "https://external-preview.redd.it/e67OvQTnkypoYCuC4ApCsgdzVGjPLX3986XW_Ttm9bU.png?width=108&crop=smart&format=pjpg&auto=webp&s=5c6d61e0d4934df3c1f4b7a4c3c3afdd4c31c037", + "width": 108, + }, + { + "height": 121, + "url": "https://external-preview.redd.it/e67OvQTnkypoYCuC4ApCsgdzVGjPLX3986XW_Ttm9bU.png?width=216&crop=smart&format=pjpg&auto=webp&s=24586000b5821e23ce78f395c1f294bbe3fa3945", + "width": 216, + }, + { + "height": 180, + "url": "https://external-preview.redd.it/e67OvQTnkypoYCuC4ApCsgdzVGjPLX3986XW_Ttm9bU.png?width=320&crop=smart&format=pjpg&auto=webp&s=dcaed0109703cbddd4914e138afdb61086cffd81", + "width": 320, + }, + { + "height": 360, + "url": "https://external-preview.redd.it/e67OvQTnkypoYCuC4ApCsgdzVGjPLX3986XW_Ttm9bU.png?width=640&crop=smart&format=pjpg&auto=webp&s=ef4f6dc33fe582b93e954114e9eb1447bbbc197b", + "width": 640, + }, + ], + "source": { + "height": 360, + "url": "https://external-preview.redd.it/e67OvQTnkypoYCuC4ApCsgdzVGjPLX3986XW_Ttm9bU.png?format=pjpg&auto=webp&s=b6e8cba9d25c684ecb7104c1e1c454dba7fd3f2f", + "width": 640, + }, + "variants": {}, + } + ], + }, + "pwls": 6, + "quarantine": False, + "removal_reason": None, + "removed_by": None, + "removed_by_category": None, + "report_reasons": None, + "saved": False, + "score": 112661, + "secure_media": { + "reddit_video": { + "dash_url": "https://v.redd.it/asj4p03rdsa51/DASHPlaylist.mpd?a=1597351258%2CY2VmYTAyMWNmZjIwZjQ4YTBmMDc5MTRjOTU0NjliZWU3MDE2YTU3NjJiYzQxZWRiODY4ZTc1YWI1NDY4MWIxNA%3D%3D&v=1&f=sd", + "duration": 30, + "fallback_url": "https://v.redd.it/asj4p03rdsa51/DASH_360.mp4?source=fallback", + "height": 360, + "hls_url": "https://v.redd.it/asj4p03rdsa51/HLSPlaylist.m3u8?a=1597351258%2CY2QxM2I4Njk5MmIyOTRiZTBhNDQ2MDg0ZTM2NTllYzBjODBlYjNiNDc1Mzg2ODIxNDk4MTAzMzYyNzlmNjI1NQ%3D%3D&v=1&f=sd", + "is_gif": False, + "scrubber_media_url": "https://v.redd.it/asj4p03rdsa51/DASH_96.mp4", + "transcoding_status": "completed", + "width": 640, + } + }, + "secure_media_embed": {}, + "selftext": "", + "selftext_html": None, + "send_replies": True, + "spoiler": False, + "stickied": False, + "subreddit": "aww", + "subreddit_id": "t5_2qh1o", + "subreddit_name_prefixed": "r/aww", + "subreddit_subscribers": 25634399, + "subreddit_type": "public", + "suggested_sort": None, + "thumbnail": "https://b.thumbs.redditmedia.com/l_4Yk7NC8hz2HM0D3Hv2dK_nZBjpL8FL3NPv9WkRo8k.jpg", + "thumbnail_height": 78, + "thumbnail_width": 140, + "title": "Bunnies flop over when they feel " + "completely safe beside their " + "protectors", + "top_awarded_type": None, + "total_awards_received": 12, + "treatment_tags": [], + "ups": 112661, + "upvote_ratio": 0.94, + "url": "https://v.redd.it/asj4p03rdsa51", + "url_overridden_by_dest": "https://v.redd.it/asj4p03rdsa51", + "user_reports": [], + "view_count": None, + "visited": False, + "whitelist_status": "all_ads", + "wls": 6, + }, + "kind": "t3", + }, + ], + "dist": 25, + "modhash": None, + }, + "kind": "Listing", +} + +external_video_mock = { + "data": { + "after": "t3_hr3mhe", + "before": None, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "aww", + "selftext": "", + "author_fullname": "t2_ot2b2", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Dog splashing in water", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/aww", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "thumbnail_height": 140, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hulh8k", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.94, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 1142, + "total_awards_received": 0, + "media_embed": { + "content": '<iframe class="embedly-embed" src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fgfycat.com%2Fifr%2Fexcellentinfantileamericanwigeon&display_name=Gfycat&url=https%3A%2F%2Fgfycat.com%2Fexcellentinfantileamericanwigeon&image=https%3A%2F%2Fthumbs.gfycat.com%2FExcellentInfantileAmericanwigeon-size_restricted.gif&key=ed8fa8699ce04833838e66ce79ba05f1&type=text%2Fhtml&schema=gfycat" width="400" height="400" scrolling="no" title="Gfycat embed" frameborder="0" allow="autoplay; fullscreen" allowfullscreen="True"></iframe>', + "width": 400, + "scrolling": False, + "height": 400, + }, + "thumbnail_width": 140, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": { + "oembed": { + "provider_url": "https://gfycat.com", + "description": 'Hi! We use cookies and similar technologies ("cookies"), including third-party cookies, on this website to help operate and improve your experience on our site, monitor our site performance, and for advertising purposes. By clicking "Accept Cookies" below, you are giving us consent to use cookies (except consent is not required for cookies necessary to run our site).', + "title": "97991217 286625482366728 7551185146460766208 n", + "author_name": "Gfycat", + "height": 400, + "width": 400, + "html": '<iframe class="embedly-embed" src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fgfycat.com%2Fifr%2Fexcellentinfantileamericanwigeon&display_name=Gfycat&url=https%3A%2F%2Fgfycat.com%2Fexcellentinfantileamericanwigeon&image=https%3A%2F%2Fthumbs.gfycat.com%2FExcellentInfantileAmericanwigeon-size_restricted.gif&key=ed8fa8699ce04833838e66ce79ba05f1&type=text%2Fhtml&schema=gfycat" width="400" height="400" scrolling="no" title="Gfycat embed" frameborder="0" allow="autoplay; fullscreen" allowfullscreen="True"></iframe>', + "thumbnail_width": 250, + "version": "1.0", + "provider_name": "Gfycat", + "thumbnail_url": "https://thumbs.gfycat.com/ExcellentInfantileAmericanwigeon-size_restricted.gif", + "type": "video", + "thumbnail_height": 250, + }, + "type": "gfycat.com", + }, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": { + "content": '<iframe class="embedly-embed" src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fgfycat.com%2Fifr%2Fexcellentinfantileamericanwigeon&display_name=Gfycat&url=https%3A%2F%2Fgfycat.com%2Fexcellentinfantileamericanwigeon&image=https%3A%2F%2Fthumbs.gfycat.com%2FExcellentInfantileAmericanwigeon-size_restricted.gif&key=ed8fa8699ce04833838e66ce79ba05f1&type=text%2Fhtml&schema=gfycat" width="400" height="400" scrolling="no" title="Gfycat embed" frameborder="0" allow="autoplay; fullscreen" allowfullscreen="True"></iframe>', + "width": 400, + "scrolling": False, + "media_domain_url": "https://www.redditmedia.com/mediaembed/hulh8k", + "height": 400, + }, + "link_flair_text": None, + "can_mod_post": False, + "score": 1142, + "approved_by": None, + "author_premium": False, + "thumbnail": "https://b.thumbs.redditmedia.com/eR_Cu4w1l9PwaM14RTEpnKD20EaK5mMxUbyK8BBDo_M.jpg", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "post_hint": "rich:video", + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [], + "created": 1595281442, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "gfycat.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://gfycat.com/excellentinfantileamericanwigeon", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "preview": { + "images": [ + { + "source": { + "url": "https://external-preview.redd.it/rZXN_aGbww8NNlhGWB-5cjHPonSST4S7aS6uaZyb_W4.jpg?auto=webp&s=2a2d3a1e0a06742bf752c1c4e1582c2fa49793a3", + "width": 250, + "height": 250, + }, + "resolutions": [ + { + "url": "https://external-preview.redd.it/rZXN_aGbww8NNlhGWB-5cjHPonSST4S7aS6uaZyb_W4.jpg?width=108&crop=smart&auto=webp&s=35f61b003416516f664682717876a94d186793ae", + "width": 108, + "height": 108, + }, + { + "url": "https://external-preview.redd.it/rZXN_aGbww8NNlhGWB-5cjHPonSST4S7aS6uaZyb_W4.jpg?width=216&crop=smart&auto=webp&s=842416c1b8f8fae758a7ba6eb98af93ee2404a8d", + "width": 216, + "height": 216, + }, + ], + "variants": {}, + "id": "IVorc9dV9K9nJhhSVFKST92dfGfmhgBQjw257DWmJcE", + } + ], + "reddit_video_preview": { + "fallback_url": "https://v.redd.it/syp9pkiu00c51/DASH_360.mp4", + "height": 400, + "width": 400, + "scrubber_media_url": "https://v.redd.it/syp9pkiu00c51/DASH_96.mp4", + "dash_url": "https://v.redd.it/syp9pkiu00c51/DASHPlaylist.mpd", + "duration": 21, + "hls_url": "https://v.redd.it/syp9pkiu00c51/HLSPlaylist.m3u8", + "is_gif": True, + "transcoding_status": "completed", + }, + "enabled": False, + }, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1o", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hulh8k", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheRikari", + "discussion_type": None, + "num_comments": 21, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_hujqxu", + "author_flair_text_color": None, + "permalink": "/r/aww/comments/hulh8k/dog_splashing_in_water/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://gfycat.com/excellentinfantileamericanwigeon", + "subreddit_subscribers": 25721914, + "created_utc": 1595252642, + "num_crossposts": 0, + "media": { + "oembed": { + "provider_url": "https://gfycat.com", + "description": 'Hi! We use cookies and similar technologies ("cookies"), including third-party cookies, on this website to help operate and improve your experience on our site, monitor our site performance, and for advertising purposes. By clicking "Accept Cookies" below, you are giving us consent to use cookies (except consent is not required for cookies necessary to run our site).', + "title": "97991217 286625482366728 7551185146460766208 n", + "author_name": "Gfycat", + "height": 400, + "width": 400, + "html": '<iframe class="embedly-embed" src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fgfycat.com%2Fifr%2Fexcellentinfantileamericanwigeon&display_name=Gfycat&url=https%3A%2F%2Fgfycat.com%2Fexcellentinfantileamericanwigeon&image=https%3A%2F%2Fthumbs.gfycat.com%2FExcellentInfantileAmericanwigeon-size_restricted.gif&key=ed8fa8699ce04833838e66ce79ba05f1&type=text%2Fhtml&schema=gfycat" width="400" height="400" scrolling="no" title="Gfycat embed" frameborder="0" allow="autoplay; fullscreen" allowfullscreen="True"></iframe>', + "thumbnail_width": 250, + "version": "1.0", + "provider_name": "Gfycat", + "thumbnail_url": "https://thumbs.gfycat.com/ExcellentInfantileAmericanwigeon-size_restricted.gif", + "type": "video", + "thumbnail_height": 250, + }, + "type": "gfycat.com", + }, + "is_video": False, + }, + } + ], + "dist": 25, + "modhash": None, + }, + "kind": "Listing", +} + +external_gifv_mock = { + "data": { + "after": "t3_hr3mhe", + "before": None, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "aww", + "selftext": "", + "author_fullname": "t2_ygx0p1u", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "if i fits i sits", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/aww", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "thumbnail_height": 74, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_humdlf", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.97, + "author_flair_background_color": "", + "subreddit_type": "public", + "ups": 7512, + "total_awards_received": 1, + "media_embed": {}, + "thumbnail_width": 140, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 7512, + "approved_by": None, + "author_premium": True, + "thumbnail": "https://b.thumbs.redditmedia.com/QHK44nUFZup-hfFX2Z1dXhk-1lPEmROUCB3bBujvTck.jpg", + "edited": False, + "author_flair_css_class": "k", + "author_flair_richtext": [], + "gildings": {}, + "post_hint": "link", + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1595284712, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.imgur.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": "confidence", + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.imgur.com/grVh2AG.gifv", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": False, + "pinned": False, + "over_18": False, + "preview": { + "images": [ + { + "source": { + "url": "https://external-preview.redd.it/XDHMFAMlcn3iuSqmWeDIc4yG-q-6xnTIdGivYX-cus4.jpg?auto=webp&s=c4ba246318b3502b080d37fcbdb12e07221401a9", + "width": 638, + "height": 338, + }, + "resolutions": [ + { + "url": "https://external-preview.redd.it/XDHMFAMlcn3iuSqmWeDIc4yG-q-6xnTIdGivYX-cus4.jpg?width=108&crop=smart&auto=webp&s=c9c340a60ba3da1af3f5d5c08f3ed618ebd567d4", + "width": 108, + "height": 57, + }, + { + "url": "https://external-preview.redd.it/XDHMFAMlcn3iuSqmWeDIc4yG-q-6xnTIdGivYX-cus4.jpg?width=216&crop=smart&auto=webp&s=d05c0415e3dc63d097264bfb1b35b09676bd24f6", + "width": 216, + "height": 114, + }, + { + "url": "https://external-preview.redd.it/XDHMFAMlcn3iuSqmWeDIc4yG-q-6xnTIdGivYX-cus4.jpg?width=320&crop=smart&auto=webp&s=5c236179ccfff29e9ba980f31d5a6a9905adbe86", + "width": 320, + "height": 169, + }, + ], + "variants": {}, + "id": "4Z8zF5e4sZJnX4vWH7pZkbqiDPMCuh2J4kNotV9AGSI", + } + ], + "reddit_video_preview": { + "fallback_url": "https://v.redd.it/zzctc8y2dzb51/DASH_240.mp4", + "height": 338, + "width": 638, + "scrubber_media_url": "https://v.redd.it/zzctc8y2dzb51/DASH_96.mp4", + "dash_url": "https://v.redd.it/zzctc8y2dzb51/DASHPlaylist.mpd", + "duration": 44, + "hls_url": "https://v.redd.it/zzctc8y2dzb51/HLSPlaylist.m3u8", + "is_gif": True, + "transcoding_status": "completed", + }, + "enabled": False, + }, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": False, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1o", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "humdlf", + "is_robot_indexable": True, + "report_reasons": None, + "author": "jasontaken", + "discussion_type": None, + "num_comments": 67, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/aww/comments/humdlf/if_i_fits_i_sits/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.imgur.com/grVh2AG.gifv", + "subreddit_subscribers": 25723833, + "created_utc": 1595255912, + "num_crossposts": 1, + "media": None, + "is_video": False, + }, + } + ], + "dist": 25, + "modhash": None, + }, + "kind": "Listing", +} + +unknown_mock = { + "data": { + "after": "t3_hr3mhe", + "before": None, + "children": [ + { + "kind": "t1", + "data": { + "approved_at_utc": None, + "subreddit": "aww", + "selftext": "", + "author_fullname": "t2_ygx0p1u", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "if i fits i sits", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/aww", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "thumbnail_height": 74, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_humdlf", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.97, + "author_flair_background_color": "", + "subreddit_type": "public", + "ups": 7512, + "total_awards_received": 1, + "media_embed": {}, + "thumbnail_width": 140, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 7512, + "approved_by": None, + "author_premium": True, + "thumbnail": "https://b.thumbs.redditmedia.com/QHK44nUFZup-hfFX2Z1dXhk-1lPEmROUCB3bBujvTck.jpg", + "edited": False, + "author_flair_css_class": "k", + "author_flair_richtext": [], + "gildings": {}, + "post_hint": "link", + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1595284712, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.imgur.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": "confidence", + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.imgur.com/grVh2AG.gifv", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": False, + "pinned": False, + "over_18": False, + "preview": { + "images": [ + { + "source": { + "url": "https://external-preview.redd.it/XDHMFAMlcn3iuSqmWeDIc4yG-q-6xnTIdGivYX-cus4.jpg?auto=webp&s=c4ba246318b3502b080d37fcbdb12e07221401a9", + "width": 638, + "height": 338, + }, + "resolutions": [ + { + "url": "https://external-preview.redd.it/XDHMFAMlcn3iuSqmWeDIc4yG-q-6xnTIdGivYX-cus4.jpg?width=108&crop=smart&auto=webp&s=c9c340a60ba3da1af3f5d5c08f3ed618ebd567d4", + "width": 108, + "height": 57, + }, + { + "url": "https://external-preview.redd.it/XDHMFAMlcn3iuSqmWeDIc4yG-q-6xnTIdGivYX-cus4.jpg?width=216&crop=smart&auto=webp&s=d05c0415e3dc63d097264bfb1b35b09676bd24f6", + "width": 216, + "height": 114, + }, + { + "url": "https://external-preview.redd.it/XDHMFAMlcn3iuSqmWeDIc4yG-q-6xnTIdGivYX-cus4.jpg?width=320&crop=smart&auto=webp&s=5c236179ccfff29e9ba980f31d5a6a9905adbe86", + "width": 320, + "height": 169, + }, + ], + "variants": {}, + "id": "4Z8zF5e4sZJnX4vWH7pZkbqiDPMCuh2J4kNotV9AGSI", + } + ], + "reddit_video_preview": { + "fallback_url": "https://v.redd.it/zzctc8y2dzb51/DASH_240.mp4", + "height": 338, + "width": 638, + "scrubber_media_url": "https://v.redd.it/zzctc8y2dzb51/DASH_96.mp4", + "dash_url": "https://v.redd.it/zzctc8y2dzb51/DASHPlaylist.mpd", + "duration": 44, + "hls_url": "https://v.redd.it/zzctc8y2dzb51/HLSPlaylist.m3u8", + "is_gif": True, + "transcoding_status": "completed", + }, + "enabled": False, + }, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": False, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1o", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "humdlf", + "is_robot_indexable": True, + "report_reasons": None, + "author": "jasontaken", + "discussion_type": None, + "num_comments": 67, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/aww/comments/humdlf/if_i_fits_i_sits/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.imgur.com/grVh2AG.gifv", + "subreddit_subscribers": 25723833, + "created_utc": 1595255912, + "num_crossposts": 1, + "media": None, + "is_video": False, + }, + } + ], + "dist": 25, + "modhash": None, + }, + "kind": "Listing", +} diff --git a/src/newsreader/news/collection/tests/reddit/builder/tests.py b/src/newsreader/news/collection/tests/reddit/builder/tests.py index eb8182a..9c1a046 100644 --- a/src/newsreader/news/collection/tests/reddit/builder/tests.py +++ b/src/newsreader/news/collection/tests/reddit/builder/tests.py @@ -86,7 +86,7 @@ class RedditBuilderTestCase(TestCase): def test_update_posts(self): subreddit = SubredditFactory() existing_post = RedditPostFactory( - remote_identifier="hngsj8", + remote_identifier="hm0qct", author="Old author", title="Old title", body="Old body", @@ -108,17 +108,24 @@ class RedditBuilderTestCase(TestCase): existing_post.refresh_from_db() - self.assertEquals(existing_post.remote_identifier, "hngsj8") - self.assertEquals(existing_post.author, "nixcraft") - self.assertEquals(existing_post.title, "KeePassXC 2.6.0 released") - self.assertEquals(existing_post.body, "") + self.assertEquals(existing_post.remote_identifier, "hm0qct") + self.assertEquals(existing_post.author, "AutoModerator") + self.assertEquals( + existing_post.title, + "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", + ) + self.assertIn( + "This megathread is also to hear opinions from anyone just starting out " + "with Linux or those that have used Linux (GNU or otherwise) for a long time.", + existing_post.body, + ) self.assertEquals( existing_post.publication_date, - pytz.utc.localize(datetime(2020, 7, 8, 15, 11, 6)), + pytz.utc.localize(datetime(2020, 7, 6, 6, 11, 22)), ) self.assertEquals( existing_post.url, - "https://www.reddit.com/r/linux/comments/hngsj8/" "keepassxc_260_released/", + "https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", ) def test_html_sanitizing(self): @@ -219,3 +226,183 @@ class RedditBuilderTestCase(TestCase): duplicate_post.title, "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", ) + + def test_image_post(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((image_mock, mock_stream)) as builder: + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual(("hr64xh", "hr4bxo", "hr14y5", "hr2fv0"), posts.keys()) + + post = posts["hr64xh"] + + title = ( + "Ya’ll, I just can’t... this is my " + "son, Judah. My wife and I have no " + "idea how we created such a " + "beautiful child." + ) + url = "https://i.redd.it/cm2qybia1va51.jpg" + + self.assertEquals( + "https://www.reddit.com/r/aww/comments/hr64xh/yall_i_just_cant_this_is_my_son_judah_my_wife_and/", + post.url, + ) + self.assertEquals( + f"
      {title}
      ", post.body + ) + + def test_external_image_post(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((external_image_mock, mock_stream)) as builder: + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual(("hr41am", "huoldn"), posts.keys()) + + post = posts["hr41am"] + + url = "http://gfycat.com/thatalivedogwoodclubgall" + title = "Excited cows have a new brush!" + + self.assertEquals( + f"", + post.body, + ) + self.assertEquals( + "https://www.reddit.com/r/aww/comments/hr41am/excited_cows_have_a_new_brush/", + post.url, + ) + + post = posts["huoldn"] + + url = "https://i.imgur.com/usfMVUJ.jpg" + title = "Novosibirsk Zoo welcomes 16 cobalt-eyed Pallas’s cat kittens" + + self.assertEquals( + f"
      {title}
      ", post.body + ) + self.assertEquals( + "https://www.reddit.com/r/aww/comments/huoldn/novosibirsk_zoo_welcomes_16_cobalteyed_pallass/", + post.url, + ) + + def test_video_post(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((video_mock, mock_stream)) as builder: + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual(("hr32jf", "hr1r00", "hqy0ny", "hr0uzh"), posts.keys()) + + post = posts["hr1r00"] + + url = "https://v.redd.it/eyvbxaeqtta51/DASH_480.mp4?source=fallback" + + self.assertEquals( + post.url, + "https://www.reddit.com/r/aww/comments/hr1r00/cool_catt_and_his_clingy_girlfriend/", + ) + self.assertEquals( + f"
      ", + post.body, + ) + + def test_external_video_post(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((external_video_mock, mock_stream)) as builder: + builder.save() + + post = Post.objects.get() + + self.assertEquals(post.remote_identifier, "hulh8k") + + self.assertEquals( + post.url, + "https://www.reddit.com/r/aww/comments/hulh8k/dog_splashing_in_water/", + ) + + title = "Dog splashing in water" + url = "https://gfycat.com/excellentinfantileamericanwigeon" + + self.assertEquals( + f"", + post.body, + ) + + def test_external_gifv_video_post(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((external_gifv_mock, mock_stream)) as builder: + builder.save() + + post = Post.objects.get() + + self.assertEquals(post.remote_identifier, "humdlf") + + self.assertEquals( + post.url, "https://www.reddit.com/r/aww/comments/humdlf/if_i_fits_i_sits/" + ) + + self.assertEquals( + "
      ", + post.body, + ) + + def test_link_only_post(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((simple_mock, mock_stream)) as builder: + builder.save() + + post = Post.objects.get(remote_identifier="hngsj8") + + title = "KeePassXC 2.6.0 released" + url = "https://keepassxc.org/blog/2020-07-07-2.6.0-released/" + + self.assertIn( + f"", + post.body, + ) + + self.assertEquals( + post.url, + "https://www.reddit.com/r/linux/comments/hngsj8/keepassxc_260_released/", + ) + + def test_skip_not_known_post_type(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((unknown_mock, mock_stream)) as builder: + builder.save() + + self.assertEquals(Post.objects.count(), 0) diff --git a/src/newsreader/news/collection/tests/views/test_subreddit_views.py b/src/newsreader/news/collection/tests/views/test_subreddit_views.py index a8de55e..0dff663 100644 --- a/src/newsreader/news/collection/tests/views/test_subreddit_views.py +++ b/src/newsreader/news/collection/tests/views/test_subreddit_views.py @@ -1,11 +1,12 @@ from django.test import TestCase from django.urls import reverse +from django.utils.translation import gettext as _ import pytz from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.models import CollectionRule -from newsreader.news.collection.reddit import REDDIT_URL +from newsreader.news.collection.reddit import REDDIT_API_URL, REDDIT_URL from newsreader.news.collection.tests.factories import SubredditFactory from newsreader.news.collection.tests.views.base import CollectionRuleViewTestCase from newsreader.news.core.tests.factories import CategoryFactory @@ -17,7 +18,7 @@ class SubRedditCreateViewTestCase(CollectionRuleViewTestCase, TestCase): self.form_data = { "name": "new rule", - "url": "https://www.reddit.com/r/aww", + "url": f"{REDDIT_API_URL}/r/aww", "category": str(self.category.pk), } @@ -31,12 +32,19 @@ class SubRedditCreateViewTestCase(CollectionRuleViewTestCase, TestCase): rule = CollectionRule.objects.get(name="new rule") self.assertEquals(rule.type, RuleTypeChoices.subreddit) - self.assertEquals(rule.url, "https://www.reddit.com/r/aww.json") + self.assertEquals(rule.url, f"{REDDIT_API_URL}/r/aww") self.assertEquals(rule.timezone, str(pytz.utc)) self.assertEquals(rule.favicon, None) self.assertEquals(rule.category.pk, self.category.pk) self.assertEquals(rule.user.pk, self.user.pk) + def test_regular_reddit_url(self): + self.form_data.update(url=f"{REDDIT_URL}/r/aww") + + response = self.client.post(self.url, self.form_data) + + self.assertContains(response, _("This does not look like an Reddit API URL")) + class SubRedditUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): def setUp(self): @@ -44,7 +52,7 @@ class SubRedditUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): self.rule = SubredditFactory( name="Python", - url=f"{REDDIT_URL}/r/python.json", + url=f"{REDDIT_API_URL}/r/python.json", user=self.user, category=self.category, type=RuleTypeChoices.subreddit, @@ -97,7 +105,7 @@ class SubRedditUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): self.assertEquals(response.status_code, 404) def test_url_change(self): - self.form_data.update(name="aww", url=f"{REDDIT_URL}/r/aww") + self.form_data.update(name="aww", url=f"{REDDIT_API_URL}/r/aww") response = self.client.post(self.url, self.form_data) @@ -106,8 +114,15 @@ class SubRedditUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): rule = CollectionRule.objects.get(name="aww") self.assertEquals(rule.type, RuleTypeChoices.subreddit) - self.assertEquals(rule.url, f"{REDDIT_URL}/r/aww.json") + self.assertEquals(rule.url, f"{REDDIT_API_URL}/r/aww") self.assertEquals(rule.timezone, str(pytz.utc)) self.assertEquals(rule.favicon, None) self.assertEquals(rule.category.pk, self.category.pk) self.assertEquals(rule.user.pk, self.user.pk) + + def test_regular_reddit_url(self): + self.form_data.update(url=f"{REDDIT_URL}/r/aww") + + response = self.client.post(self.url, self.form_data) + + self.assertContains(response, _("This does not look like an Reddit API URL")) diff --git a/src/newsreader/news/core/tests/endpoints/category/detail/tests.py b/src/newsreader/news/core/tests/endpoints/category/detail/tests.py index 864a144..1f42a20 100644 --- a/src/newsreader/news/core/tests/endpoints/category/detail/tests.py +++ b/src/newsreader/news/core/tests/endpoints/category/detail/tests.py @@ -4,8 +4,8 @@ from django.test import TestCase from django.urls import reverse from newsreader.accounts.tests.factories import UserFactory -from newsreader.news.collection.tests.factories import CollectionRuleFactory -from newsreader.news.core.tests.factories import CategoryFactory, PostFactory +from newsreader.news.collection.tests.factories import FeedFactory +from newsreader.news.core.tests.factories import CategoryFactory, FeedPostFactory class CategoryDetailViewTestCase(TestCase): @@ -116,11 +116,11 @@ class CategoryDetailViewTestCase(TestCase): def test_read_count(self): category = CategoryFactory(user=self.user) - unread_rule = CollectionRuleFactory(category=category) - read_rule = CollectionRuleFactory(category=category) + unread_rule = FeedFactory(category=category) + read_rule = FeedFactory(category=category) - PostFactory.create_batch(size=20, read=False, rule=unread_rule) - PostFactory.create_batch(size=20, read=True, rule=read_rule) + FeedPostFactory.create_batch(size=20, read=False, rule=unread_rule) + FeedPostFactory.create_batch(size=20, read=True, rule=read_rule) response = self.client.get( reverse("api:news:core:categories-detail", args=[category.pk]) @@ -139,8 +139,8 @@ class CategoryReadTestCase(TestCase): def test_category_read(self): category = CategoryFactory(user=self.user) rules = [ - PostFactory.create_batch(size=5, read=False, rule=rule) - for rule in CollectionRuleFactory.create_batch(size=5, category=category) + FeedPostFactory.create_batch(size=5, read=False, rule=rule) + for rule in FeedFactory.create_batch(size=5, category=category) ] response = self.client.post( @@ -165,8 +165,8 @@ class CategoryReadTestCase(TestCase): category = CategoryFactory(user=self.user) rules = [ - PostFactory.create_batch(size=5, read=False, rule=rule) - for rule in CollectionRuleFactory.create_batch( + FeedPostFactory.create_batch(size=5, read=False, rule=rule) + for rule in FeedFactory.create_batch( size=5, category=category, user=self.user ) ] @@ -182,8 +182,8 @@ class CategoryReadTestCase(TestCase): category = CategoryFactory(user=other_user) rules = [ - PostFactory.create_batch(size=5, read=False, rule=rule) - for rule in CollectionRuleFactory.create_batch( + FeedPostFactory.create_batch(size=5, read=False, rule=rule) + for rule in FeedFactory.create_batch( size=5, category=category, user=other_user ) ] diff --git a/src/newsreader/news/core/tests/endpoints/category/list/tests.py b/src/newsreader/news/core/tests/endpoints/category/list/tests.py index 4d5f0e6..15fb166 100644 --- a/src/newsreader/news/core/tests/endpoints/category/list/tests.py +++ b/src/newsreader/news/core/tests/endpoints/category/list/tests.py @@ -8,8 +8,8 @@ from django.urls import reverse import pytz from newsreader.accounts.tests.factories import UserFactory -from newsreader.news.collection.tests.factories import CollectionRuleFactory -from newsreader.news.core.tests.factories import CategoryFactory, PostFactory +from newsreader.news.collection.tests.factories import FeedFactory +from newsreader.news.core.tests.factories import CategoryFactory, FeedPostFactory class CategoryListViewTestCase(TestCase): @@ -125,7 +125,7 @@ class NestedCategoryListViewTestCase(TestCase): def test_simple(self): category = CategoryFactory.create(user=self.user) - rules = CollectionRuleFactory.create_batch(size=5, category=category) + rules = FeedFactory.create_batch(size=5, category=category) response = self.client.get( reverse("api:news:core:categories-nested-rules", kwargs={"pk": category.pk}) @@ -219,7 +219,7 @@ class NestedCategoryListViewTestCase(TestCase): self.client.logout() category = CategoryFactory.create(user=self.user) - rules = CollectionRuleFactory.create_batch(size=5, category=category) + rules = FeedFactory.create_batch(size=5, category=category) response = self.client.get( reverse("api:news:core:categories-nested-rules", kwargs={"pk": category.pk}) @@ -231,7 +231,7 @@ class NestedCategoryListViewTestCase(TestCase): other_user = UserFactory.create() category = CategoryFactory.create(user=other_user) - rules = CollectionRuleFactory.create_batch(size=5, category=category) + rules = FeedFactory.create_batch(size=5, category=category) response = self.client.get( reverse("api:news:core:categories-nested-rules", kwargs={"pk": category.pk}) @@ -242,9 +242,9 @@ class NestedCategoryListViewTestCase(TestCase): def test_ordering(self): category = CategoryFactory.create(user=self.user) rules = [ - CollectionRuleFactory.create(category=category, name="Durp"), - CollectionRuleFactory.create(category=category, name="Slurp"), - CollectionRuleFactory.create(category=category, name="Burp"), + FeedFactory.create(category=category, name="Durp"), + FeedFactory.create(category=category, name="Slurp"), + FeedFactory.create(category=category, name="Burp"), ] response = self.client.get( @@ -261,13 +261,13 @@ class NestedCategoryListViewTestCase(TestCase): def test_only_rules_from_category_are_returned(self): other_category = CategoryFactory(user=self.user) - CollectionRuleFactory.create_batch(size=5, category=other_category) + FeedFactory.create_batch(size=5, category=other_category) category = CategoryFactory.create(user=self.user) rules = [ - CollectionRuleFactory.create(category=category, name="Durp"), - CollectionRuleFactory.create(category=category, name="Slurp"), - CollectionRuleFactory.create(category=category, name="Burp"), + FeedFactory.create(category=category, name="Durp"), + FeedFactory.create(category=category, name="Slurp"), + FeedFactory.create(category=category, name="Burp"), ] response = self.client.get( @@ -291,8 +291,8 @@ class NestedCategoryPostView(TestCase): def test_simple(self): category = CategoryFactory.create(user=self.user) rules = { - rule.pk: PostFactory.create_batch(size=5, rule=rule) - for rule in CollectionRuleFactory.create_batch( + rule.pk: FeedPostFactory.create_batch(size=5, rule=rule) + for rule in FeedFactory.create_batch( size=5, category=category, user=self.user ) } @@ -327,9 +327,7 @@ class NestedCategoryPostView(TestCase): def test_no_posts(self): category = CategoryFactory.create(user=self.user) - rules = CollectionRuleFactory.create_batch( - size=5, user=self.user, category=category - ) + rules = FeedFactory.create_batch(size=5, user=self.user, category=category) response = self.client.get( reverse("api:news:core:categories-nested-posts", kwargs={"pk": category.pk}) @@ -427,25 +425,23 @@ class NestedCategoryPostView(TestCase): def test_ordering(self): category = CategoryFactory.create(user=self.user) - bbc_rule = CollectionRuleFactory.create( - name="BBC", category=category, user=self.user - ) - guardian_rule = CollectionRuleFactory.create( + bbc_rule = FeedFactory.create(name="BBC", category=category, user=self.user) + guardian_rule = FeedFactory.create( name="The Guardian", category=category, user=self.user ) - reuters_rule = CollectionRuleFactory.create( + reuters_rule = FeedFactory.create( name="Reuters", category=category, user=self.user ) reuters_rule = [ - PostFactory.create( + FeedPostFactory.create( title="Second Reuters post", rule=reuters_rule, publication_date=datetime.combine( date(2019, 5, 21), time(hour=16, minute=7, second=37), pytz.utc ), ), - PostFactory.create( + FeedPostFactory.create( title="First Reuters post", rule=reuters_rule, publication_date=datetime.combine( @@ -455,14 +451,14 @@ class NestedCategoryPostView(TestCase): ] guardian_posts = [ - PostFactory.create( + FeedPostFactory.create( title="Second Guardian post", rule=guardian_rule, publication_date=datetime.combine( date(2019, 5, 21), time(hour=16, minute=7, second=37), pytz.utc ), ), - PostFactory.create( + FeedPostFactory.create( title="First Guardian post", rule=guardian_rule, publication_date=datetime.combine( @@ -472,14 +468,14 @@ class NestedCategoryPostView(TestCase): ] bbc_posts = [ - PostFactory.create( + FeedPostFactory.create( title="Second BBC post", rule=bbc_rule, publication_date=datetime.combine( date(2019, 5, 21), time(hour=16, minute=7, second=37), pytz.utc ), ), - PostFactory.create( + FeedPostFactory.create( title="First BBC post", rule=bbc_rule, publication_date=datetime.combine( @@ -509,19 +505,19 @@ class NestedCategoryPostView(TestCase): category = CategoryFactory.create(user=self.user) other_category = CategoryFactory.create(user=self.user) - guardian_rule = CollectionRuleFactory.create( + guardian_rule = FeedFactory.create( name="BBC", category=category, user=self.user ) - other_rule = CollectionRuleFactory.create(name="The Guardian", user=self.user) + other_rule = FeedFactory.create(name="The Guardian", user=self.user) guardian_posts = [ - PostFactory.create(rule=guardian_rule), - PostFactory.create(rule=guardian_rule), + FeedPostFactory.create(rule=guardian_rule), + FeedPostFactory.create(rule=guardian_rule), ] other_posts = [ - PostFactory.create(rule=other_rule), - PostFactory.create(rule=other_rule), + FeedPostFactory.create(rule=other_rule), + FeedPostFactory.create(rule=other_rule), ] response = self.client.get( @@ -538,10 +534,10 @@ class NestedCategoryPostView(TestCase): def test_unread_posts(self): category = CategoryFactory.create(user=self.user) - rule = CollectionRuleFactory(category=category) + rule = FeedFactory(category=category) - PostFactory.create_batch(size=10, rule=rule, read=False) - PostFactory.create_batch(size=10, rule=rule, read=True) + FeedPostFactory.create_batch(size=10, rule=rule, read=False) + FeedPostFactory.create_batch(size=10, rule=rule, read=True) response = self.client.get( reverse( @@ -561,10 +557,10 @@ class NestedCategoryPostView(TestCase): def test_read_posts(self): category = CategoryFactory.create(user=self.user) - rule = CollectionRuleFactory(category=category) + rule = FeedFactory(category=category) - PostFactory.create_batch(size=20, rule=rule, read=False) - PostFactory.create_batch(size=10, rule=rule, read=True) + FeedPostFactory.create_batch(size=20, rule=rule, read=False) + FeedPostFactory.create_batch(size=10, rule=rule, read=True) response = self.client.get( reverse( diff --git a/src/newsreader/news/core/tests/endpoints/post/detail/tests.py b/src/newsreader/news/core/tests/endpoints/post/detail/tests.py index c804ff5..2d25a89 100644 --- a/src/newsreader/news/core/tests/endpoints/post/detail/tests.py +++ b/src/newsreader/news/core/tests/endpoints/post/detail/tests.py @@ -4,8 +4,8 @@ from django.test import TestCase from django.urls import reverse from newsreader.accounts.tests.factories import UserFactory -from newsreader.news.collection.tests.factories import CollectionRuleFactory -from newsreader.news.core.tests.factories import CategoryFactory, PostFactory +from newsreader.news.collection.tests.factories import FeedFactory +from newsreader.news.core.tests.factories import CategoryFactory, FeedPostFactory class PostDetailViewTestCase(TestCase): @@ -14,10 +14,8 @@ class PostDetailViewTestCase(TestCase): self.client.force_login(self.user) def test_simple(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - post = PostFactory(rule=rule) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = FeedPostFactory(rule=rule) response = self.client.get( reverse("api:news:core:posts-detail", args=[post.pk]) @@ -43,10 +41,8 @@ class PostDetailViewTestCase(TestCase): self.assertEquals(data["detail"], "Not found.") def test_post(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - post = PostFactory(rule=rule) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = FeedPostFactory(rule=rule) response = self.client.post( reverse("api:news:core:posts-detail", args=[post.pk]) @@ -57,10 +53,8 @@ class PostDetailViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "POST" not allowed.') def test_patch(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - post = PostFactory(title="This is clickbait for sure", rule=rule) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = FeedPostFactory(title="This is clickbait for sure", rule=rule) response = self.client.patch( reverse("api:news:core:posts-detail", args=[post.pk]), @@ -73,10 +67,8 @@ class PostDetailViewTestCase(TestCase): self.assertEquals(data["title"], "This title is very accurate") def test_identifier_cannot_be_changed(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - post = PostFactory(title="This is clickbait for sure", rule=rule) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = FeedPostFactory(title="This is clickbait for sure", rule=rule) response = self.client.patch( reverse("api:news:core:posts-detail", args=[post.pk]), @@ -89,13 +81,9 @@ class PostDetailViewTestCase(TestCase): self.assertEquals(data["id"], post.pk) def test_rule_cannot_be_changed(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - new_rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - post = PostFactory(title="This is clickbait for sure", rule=rule) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + new_rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = FeedPostFactory(title="This is clickbait for sure", rule=rule) response = self.client.patch( reverse("api:news:core:posts-detail", args=[post.pk]), @@ -115,10 +103,8 @@ class PostDetailViewTestCase(TestCase): self.assertTrue(data["rule"], rule.pk) def test_put(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - post = PostFactory(title="This is clickbait for sure", rule=rule) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = FeedPostFactory(title="This is clickbait for sure", rule=rule) response = self.client.put( reverse("api:news:core:posts-detail", args=[post.pk]), @@ -131,10 +117,8 @@ class PostDetailViewTestCase(TestCase): self.assertEquals(data["title"], "This title is very accurate") def test_delete(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - post = PostFactory(rule=rule) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = FeedPostFactory(rule=rule) response = self.client.delete( reverse("api:news:core:posts-detail", args=[post.pk]) @@ -147,8 +131,8 @@ class PostDetailViewTestCase(TestCase): def test_post_with_unauthenticated_user_without_category(self): self.client.logout() - rule = CollectionRuleFactory(user=self.user, category=None) - post = PostFactory(rule=rule) + rule = FeedFactory(user=self.user, category=None) + post = FeedPostFactory(rule=rule) response = self.client.get( reverse("api:news:core:posts-detail", args=[post.pk]) @@ -159,10 +143,8 @@ class PostDetailViewTestCase(TestCase): def test_post_with_unauthenticated_user_with_category(self): self.client.logout() - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - post = PostFactory(rule=rule) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = FeedPostFactory(rule=rule) response = self.client.get( reverse("api:news:core:posts-detail", args=[post.pk]) @@ -172,8 +154,8 @@ class PostDetailViewTestCase(TestCase): def test_post_with_unauthorized_user_without_category(self): other_user = UserFactory() - rule = CollectionRuleFactory(user=other_user, category=None) - post = PostFactory(rule=rule) + rule = FeedFactory(user=other_user, category=None) + post = FeedPostFactory(rule=rule) response = self.client.get( reverse("api:news:core:posts-detail", args=[post.pk]) @@ -183,10 +165,8 @@ class PostDetailViewTestCase(TestCase): def test_post_with_unauthorized_user_with_category(self): other_user = UserFactory() - rule = CollectionRuleFactory( - user=other_user, category=CategoryFactory(user=other_user) - ) - post = PostFactory(rule=rule) + rule = FeedFactory(user=other_user, category=CategoryFactory(user=other_user)) + post = FeedPostFactory(rule=rule) response = self.client.get( reverse("api:news:core:posts-detail", args=[post.pk]) @@ -196,10 +176,8 @@ class PostDetailViewTestCase(TestCase): def test_post_with_different_user_for_category_and_rule(self): other_user = UserFactory() - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=other_user) - ) - post = PostFactory(rule=rule) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=other_user)) + post = FeedPostFactory(rule=rule) response = self.client.get( reverse("api:news:core:posts-detail", args=[post.pk]) @@ -208,10 +186,8 @@ class PostDetailViewTestCase(TestCase): self.assertEquals(response.status_code, 403) def test_mark_read(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - post = PostFactory(rule=rule, read=False) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = FeedPostFactory(rule=rule, read=False) response = self.client.patch( reverse("api:news:core:posts-detail", args=[post.pk]), @@ -224,10 +200,8 @@ class PostDetailViewTestCase(TestCase): self.assertEquals(data["read"], True) def test_mark_unread(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - post = PostFactory(rule=rule, read=True) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + post = FeedPostFactory(rule=rule, read=True) response = self.client.patch( reverse("api:news:core:posts-detail", args=[post.pk]), diff --git a/src/newsreader/news/core/tests/endpoints/post/list/tests.py b/src/newsreader/news/core/tests/endpoints/post/list/tests.py index 3800b64..3bf9d17 100644 --- a/src/newsreader/news/core/tests/endpoints/post/list/tests.py +++ b/src/newsreader/news/core/tests/endpoints/post/list/tests.py @@ -6,8 +6,8 @@ from django.urls import reverse import pytz from newsreader.accounts.tests.factories import UserFactory -from newsreader.news.collection.tests.factories import CollectionRuleFactory -from newsreader.news.core.tests.factories import CategoryFactory, PostFactory +from newsreader.news.collection.tests.factories import FeedFactory +from newsreader.news.core.tests.factories import CategoryFactory, FeedPostFactory class PostListViewTestCase(TestCase): @@ -16,10 +16,8 @@ class PostListViewTestCase(TestCase): self.client.force_login(self.user) def test_simple(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - PostFactory.create_batch(size=3, rule=rule) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + FeedPostFactory.create_batch(size=3, rule=rule) response = self.client.get(reverse("api:news:core:posts-list")) data = response.json() @@ -30,26 +28,24 @@ class PostListViewTestCase(TestCase): self.assertEquals(data["count"], 3) def test_ordering(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) posts = [ - PostFactory( + FeedPostFactory( title="I'm the first post", rule=rule, publication_date=datetime.combine( date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc ), ), - PostFactory( + FeedPostFactory( title="I'm the second post", rule=rule, publication_date=datetime.combine( date(2019, 7, 20), time(hour=18, minute=7, second=37), pytz.utc ), ), - PostFactory( + FeedPostFactory( title="I'm the third post", rule=rule, publication_date=datetime.combine( @@ -71,10 +67,8 @@ class PostListViewTestCase(TestCase): self.assertEquals(data["results"][2]["id"], posts[0].pk) def test_pagination_count(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) - PostFactory.create_batch(size=80, rule=rule) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) + FeedPostFactory.create_batch(size=80, rule=rule) page_size = 50 response = self.client.get(reverse("api:news:core:posts-list"), {"count": 50}) @@ -126,7 +120,7 @@ class PostListViewTestCase(TestCase): def test_posts_with_unauthenticated_user_without_category(self): self.client.logout() - PostFactory.create_batch(size=3, rule=CollectionRuleFactory(user=self.user)) + FeedPostFactory.create_batch(size=3, rule=FeedFactory(user=self.user)) response = self.client.get(reverse("api:news:core:posts-list")) @@ -137,8 +131,8 @@ class PostListViewTestCase(TestCase): category = CategoryFactory(user=self.user) - PostFactory.create_batch( - size=3, rule=CollectionRuleFactory(user=self.user, category=category) + FeedPostFactory.create_batch( + size=3, rule=FeedFactory(user=self.user, category=category) ) response = self.client.get(reverse("api:news:core:posts-list")) @@ -148,8 +142,8 @@ class PostListViewTestCase(TestCase): def test_posts_with_unauthorized_user_without_category(self): other_user = UserFactory() - rule = CollectionRuleFactory(user=other_user, category=None) - PostFactory.create_batch(size=3, rule=rule) + rule = FeedFactory(user=other_user, category=None) + FeedPostFactory.create_batch(size=3, rule=rule) response = self.client.get(reverse("api:news:core:posts-list")) data = response.json() @@ -162,8 +156,8 @@ class PostListViewTestCase(TestCase): other_user = UserFactory() category = CategoryFactory(user=other_user) - PostFactory.create_batch( - size=3, rule=CollectionRuleFactory(user=other_user, category=category) + FeedPostFactory.create_batch( + size=3, rule=FeedFactory(user=other_user, category=category) ) response = self.client.get(reverse("api:news:core:posts-list")) @@ -178,10 +172,8 @@ class PostListViewTestCase(TestCase): def test_posts_with_authorized_rule_unauthorized_category(self): other_user = UserFactory() - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=other_user) - ) - PostFactory.create_batch(size=3, rule=rule) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=other_user)) + FeedPostFactory.create_batch(size=3, rule=rule) response = self.client.get(reverse("api:news:core:posts-list")) data = response.json() @@ -192,8 +184,8 @@ class PostListViewTestCase(TestCase): self.assertEquals(data["count"], 0) def test_posts_with_authorized_user_without_category(self): - rule = CollectionRuleFactory(user=self.user, category=None) - PostFactory.create_batch(size=3, rule=rule) + rule = FeedFactory(user=self.user, category=None) + FeedPostFactory.create_batch(size=3, rule=rule) response = self.client.get(reverse("api:news:core:posts-list")) data = response.json() @@ -204,12 +196,10 @@ class PostListViewTestCase(TestCase): self.assertEquals(data["count"], 3) def test_unread_posts(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) - PostFactory.create_batch(size=10, rule=rule, read=False) - PostFactory.create_batch(size=10, rule=rule, read=True) + FeedPostFactory.create_batch(size=10, rule=rule, read=False) + FeedPostFactory.create_batch(size=10, rule=rule, read=True) response = self.client.get( reverse("api:news:core:posts-list"), {"read": "false"} @@ -225,12 +215,10 @@ class PostListViewTestCase(TestCase): self.assertEquals(post["read"], False) def test_read_posts(self): - rule = CollectionRuleFactory( - user=self.user, category=CategoryFactory(user=self.user) - ) + rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user)) - PostFactory.create_batch(size=20, rule=rule, read=False) - PostFactory.create_batch(size=10, rule=rule, read=True) + FeedPostFactory.create_batch(size=20, rule=rule, read=False) + FeedPostFactory.create_batch(size=10, rule=rule, read=True) response = self.client.get( reverse("api:news:core:posts-list"), {"read": "true"} diff --git a/src/newsreader/news/core/tests/factories.py b/src/newsreader/news/core/tests/factories.py index 966e70b..520f940 100644 --- a/src/newsreader/news/core/tests/factories.py +++ b/src/newsreader/news/core/tests/factories.py @@ -3,7 +3,7 @@ import factory.fuzzy import pytz from newsreader.accounts.tests.factories import UserFactory -from newsreader.news.collection.reddit import REDDIT_URL +from newsreader.news.collection.reddit import REDDIT_API_URL from newsreader.news.core.models import Category, Post @@ -33,9 +33,12 @@ class PostFactory(factory.django.DjangoModelFactory): model = Post +class FeedPostFactory(PostFactory): + rule = factory.SubFactory("newsreader.news.collection.tests.factories.FeedFactory") + + class RedditPostFactory(PostFactory): - remote_identifier = factory.Faker("uuid4") - url = factory.fuzzy.FuzzyText(length=10, prefix=f"{REDDIT_URL}/") + url = factory.fuzzy.FuzzyText(length=10, prefix=f"{REDDIT_API_URL}/") rule = factory.SubFactory( "newsreader.news.collection.tests.factories.SubredditFactory" ) diff --git a/src/newsreader/scss/components/post/_post.scss b/src/newsreader/scss/components/post/_post.scss index 46d389d..6b41844 100644 --- a/src/newsreader/scss/components/post/_post.scss +++ b/src/newsreader/scss/components/post/_post.scss @@ -68,14 +68,9 @@ margin: 20px 0 5px 0; } - & img { - padding: 10px 10px 30px 10px; - - max-width: 70%; - width: inherit; - height: 100%; - - align-self: center; + & img, video { + padding: 10px 0; + max-width: 100%; } } From 2e56d3208b9cecf9f36ae17e69751b2dd2e393c5 Mon Sep 17 00:00:00 2001 From: sonny Date: Sat, 25 Jul 2020 16:35:28 +0200 Subject: [PATCH 132/422] Update deploy job --- gitlab-ci/deploy.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/gitlab-ci/deploy.yml b/gitlab-ci/deploy.yml index fedc5eb..365c776 100644 --- a/gitlab-ci/deploy.yml +++ b/gitlab-ci/deploy.yml @@ -5,12 +5,19 @@ deploy: name: production url: rss.fudiggity.nl before_script: - - apt-get update && apt-get install -y ansible git + - apt-get update && apt-get install --quiet --quiet --assume-yes ansible git - git clone https://gitlab-ci-token:${CI_JOB_TOKEN}@git.fudiggity.nl/sonny/ansible-playbooks.git deployment - mkdir /root/.ssh - echo "192.168.178.63 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILbtcdgJBhVCKsO88cV19EYefDTopdYejEQCp1pYr1Ga" > /root/.ssh/known_hosts - echo "$DEPLOY_KEY" > deployment/deploy_key && chmod 0600 deployment/deploy_key + - mkdir /root/.vaults + - echo "$VAULT_PASSWORD" > /root/.vaults/newsreader && chmod 0600 /root/.vaults/newsreader script: - - ansible-playbook deployment/playbook.yml --inventory deployment/apps.yml --limit newsreader --user ansible --private-key deployment/deploy_key + - ansible-playbook deployment/playbook.yml \ + --inventory deployment/apps.yml \ + --limit newsreader \ + --user ansible \ + --private-key deployment/deploy_key \ + --vault-password-file /root/.vaults/newsreader only: - master From 2a40311a874e4f7bc87d12ca27f115e4159ca67b Mon Sep 17 00:00:00 2001 From: sonny Date: Sat, 25 Jul 2020 16:40:37 +0200 Subject: [PATCH 133/422] Update deploy job --- gitlab-ci/deploy.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/gitlab-ci/deploy.yml b/gitlab-ci/deploy.yml index fedc5eb..365c776 100644 --- a/gitlab-ci/deploy.yml +++ b/gitlab-ci/deploy.yml @@ -5,12 +5,19 @@ deploy: name: production url: rss.fudiggity.nl before_script: - - apt-get update && apt-get install -y ansible git + - apt-get update && apt-get install --quiet --quiet --assume-yes ansible git - git clone https://gitlab-ci-token:${CI_JOB_TOKEN}@git.fudiggity.nl/sonny/ansible-playbooks.git deployment - mkdir /root/.ssh - echo "192.168.178.63 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILbtcdgJBhVCKsO88cV19EYefDTopdYejEQCp1pYr1Ga" > /root/.ssh/known_hosts - echo "$DEPLOY_KEY" > deployment/deploy_key && chmod 0600 deployment/deploy_key + - mkdir /root/.vaults + - echo "$VAULT_PASSWORD" > /root/.vaults/newsreader && chmod 0600 /root/.vaults/newsreader script: - - ansible-playbook deployment/playbook.yml --inventory deployment/apps.yml --limit newsreader --user ansible --private-key deployment/deploy_key + - ansible-playbook deployment/playbook.yml \ + --inventory deployment/apps.yml \ + --limit newsreader \ + --user ansible \ + --private-key deployment/deploy_key \ + --vault-password-file /root/.vaults/newsreader only: - master From 4df50b3a169aeca2e6317ad0f7343be97bd272d9 Mon Sep 17 00:00:00 2001 From: sonny Date: Sat, 25 Jul 2020 16:53:13 +0200 Subject: [PATCH 134/422] Fix multiline yaml statement --- gitlab-ci/deploy.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/gitlab-ci/deploy.yml b/gitlab-ci/deploy.yml index 365c776..6eaa01f 100644 --- a/gitlab-ci/deploy.yml +++ b/gitlab-ci/deploy.yml @@ -13,11 +13,12 @@ deploy: - mkdir /root/.vaults - echo "$VAULT_PASSWORD" > /root/.vaults/newsreader && chmod 0600 /root/.vaults/newsreader script: - - ansible-playbook deployment/playbook.yml \ - --inventory deployment/apps.yml \ - --limit newsreader \ - --user ansible \ - --private-key deployment/deploy_key \ - --vault-password-file /root/.vaults/newsreader + - > + ansible-playbook deployment/playbook.yml + --inventory deployment/apps.yml + --limit newsreader + --user ansible + --private-key deployment/deploy_key + --vault-password-file /root/.vaults/newsreader only: - master From 9d6a79d55d87d8cc08a586e6f02b65423e071958 Mon Sep 17 00:00:00 2001 From: sonny Date: Sat, 25 Jul 2020 16:55:23 +0200 Subject: [PATCH 135/422] Squashed commit of the following: commit f1db9b9dc1026760a43028e548572db4e639976e Author: Sonny Date: Mon Mar 16 20:47:15 2020 +0100 Add port setting --- gitlab-ci/deploy.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/gitlab-ci/deploy.yml b/gitlab-ci/deploy.yml index 365c776..6eaa01f 100644 --- a/gitlab-ci/deploy.yml +++ b/gitlab-ci/deploy.yml @@ -13,11 +13,12 @@ deploy: - mkdir /root/.vaults - echo "$VAULT_PASSWORD" > /root/.vaults/newsreader && chmod 0600 /root/.vaults/newsreader script: - - ansible-playbook deployment/playbook.yml \ - --inventory deployment/apps.yml \ - --limit newsreader \ - --user ansible \ - --private-key deployment/deploy_key \ - --vault-password-file /root/.vaults/newsreader + - > + ansible-playbook deployment/playbook.yml + --inventory deployment/apps.yml + --limit newsreader + --user ansible + --private-key deployment/deploy_key + --vault-password-file /root/.vaults/newsreader only: - master From ac9e6a7224afccbd40f3247cf11f21c49bfad055 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sat, 25 Jul 2020 22:11:41 +0200 Subject: [PATCH 136/422] Remove rounded component styling --- src/newsreader/scss/components/card/_card.scss | 1 - src/newsreader/scss/components/errorlist/_errorlist.scss | 1 - src/newsreader/scss/components/form/_form.scss | 1 - src/newsreader/scss/components/messages/_messages.scss | 2 -- src/newsreader/scss/components/modal/_post-modal.scss | 1 - src/newsreader/scss/components/section/_text-section.scss | 1 - src/newsreader/scss/components/sidebar/_sidebar.scss | 1 - src/newsreader/scss/pages/login/index.scss | 2 -- 8 files changed, 10 deletions(-) diff --git a/src/newsreader/scss/components/card/_card.scss b/src/newsreader/scss/components/card/_card.scss index a9f957e..9866d4d 100644 --- a/src/newsreader/scss/components/card/_card.scss +++ b/src/newsreader/scss/components/card/_card.scss @@ -6,7 +6,6 @@ padding: 15px; width: 50%; - border-radius: 5px; background-color: $white; diff --git a/src/newsreader/scss/components/errorlist/_errorlist.scss b/src/newsreader/scss/components/errorlist/_errorlist.scss index 006dafb..6dbc458 100644 --- a/src/newsreader/scss/components/errorlist/_errorlist.scss +++ b/src/newsreader/scss/components/errorlist/_errorlist.scss @@ -14,7 +14,6 @@ padding: 10px; background-color: $error-red; - border-radius: 5px; } & li { diff --git a/src/newsreader/scss/components/form/_form.scss b/src/newsreader/scss/components/form/_form.scss index 79d3e43..089c4f1 100644 --- a/src/newsreader/scss/components/form/_form.scss +++ b/src/newsreader/scss/components/form/_form.scss @@ -5,7 +5,6 @@ flex-direction: column; width: 70%; - border-radius: 5px; background-color: $white; diff --git a/src/newsreader/scss/components/messages/_messages.scss b/src/newsreader/scss/components/messages/_messages.scss index 1d46932..b8ee6b5 100644 --- a/src/newsreader/scss/components/messages/_messages.scss +++ b/src/newsreader/scss/components/messages/_messages.scss @@ -17,8 +17,6 @@ padding: 20px 15px; margin: 5px 0; - border-radius: 5px; - background-color: $blue; &--error { diff --git a/src/newsreader/scss/components/modal/_post-modal.scss b/src/newsreader/scss/components/modal/_post-modal.scss index f357d77..f6483fe 100644 --- a/src/newsreader/scss/components/modal/_post-modal.scss +++ b/src/newsreader/scss/components/modal/_post-modal.scss @@ -4,6 +4,5 @@ margin: 0; padding: 0; - border-radius: 0; cursor: pointer; } diff --git a/src/newsreader/scss/components/section/_text-section.scss b/src/newsreader/scss/components/section/_text-section.scss index 9c5e8fc..bab9f6a 100644 --- a/src/newsreader/scss/components/section/_text-section.scss +++ b/src/newsreader/scss/components/section/_text-section.scss @@ -2,7 +2,6 @@ @extend .section; width: 70%; - border-radius: 5px; padding: 10px; diff --git a/src/newsreader/scss/components/sidebar/_sidebar.scss b/src/newsreader/scss/components/sidebar/_sidebar.scss index 89df180..f13faf3 100644 --- a/src/newsreader/scss/components/sidebar/_sidebar.scss +++ b/src/newsreader/scss/components/sidebar/_sidebar.scss @@ -15,7 +15,6 @@ overflow: auto; list-style: none; - border-radius: 5px; &__item { padding: 2px 10px 5px 10px; diff --git a/src/newsreader/scss/pages/login/index.scss b/src/newsreader/scss/pages/login/index.scss index 82b9457..f1805ed 100644 --- a/src/newsreader/scss/pages/login/index.scss +++ b/src/newsreader/scss/pages/login/index.scss @@ -2,8 +2,6 @@ margin: 5% auto; width: 50%; - border-radius: 4px; - & .form { @extend .form; From 632b3b14f12cf69925287e8679e38551b2883aa1 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 26 Jul 2020 11:57:48 +0200 Subject: [PATCH 137/422] Extend UserAdmin To allow changing password --- src/newsreader/accounts/admin.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/newsreader/accounts/admin.py b/src/newsreader/accounts/admin.py index e0b5eed..49390c7 100644 --- a/src/newsreader/accounts/admin.py +++ b/src/newsreader/accounts/admin.py @@ -1,11 +1,13 @@ from django import forms from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin +from django.contrib.auth.forms import UserChangeForm from django.utils.translation import ugettext as _ from newsreader.accounts.models import User -class UserAdminForm(forms.ModelForm): +class UserAdminForm(UserChangeForm): class Meta: widgets = { "email": forms.EmailInput(attrs={"size": "50"}), @@ -14,7 +16,7 @@ class UserAdminForm(forms.ModelForm): } -class UserAdmin(admin.ModelAdmin): +class UserAdmin(DjangoUserAdmin): list_display = ("email", "last_name", "date_joined", "is_active") list_filter = ("is_active", "is_staff", "is_superuser") ordering = ("email",) @@ -26,7 +28,7 @@ class UserAdmin(admin.ModelAdmin): fieldsets = ( ( _("User settings"), - {"fields": ("email", "first_name", "last_name", "is_active")}, + {"fields": ("email", "password", "first_name", "last_name", "is_active")}, ), ( _("Reddit settings"), From c52c30fb089f6dd281c567e8a2fb4f954cd08be8 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 26 Jul 2020 12:03:30 +0200 Subject: [PATCH 138/422] Update default fixture --- src/newsreader/fixtures/default-fixture.json | 6863 ++++++++++-------- 1 file changed, 4023 insertions(+), 2840 deletions(-) diff --git a/src/newsreader/fixtures/default-fixture.json b/src/newsreader/fixtures/default-fixture.json index 4117cc1..10d6416 100644 --- a/src/newsreader/fixtures/default-fixture.json +++ b/src/newsreader/fixtures/default-fixture.json @@ -1,2840 +1,4023 @@ -[ -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "admin", - "model": "logentry" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "auth", - "model": "permission" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "auth", - "model": "group" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "contenttypes", - "model": "contenttype" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "sessions", - "model": "session" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "django_celery_beat", - "model": "crontabschedule" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "django_celery_beat", - "model": "intervalschedule" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "django_celery_beat", - "model": "periodictask" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "django_celery_beat", - "model": "periodictasks" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "django_celery_beat", - "model": "solarschedule" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "django_celery_beat", - "model": "clockedschedule" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "registration", - "model": "registrationprofile" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "registration", - "model": "supervisedregistrationprofile" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "axes", - "model": "accessattempt" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "axes", - "model": "accesslog" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "accounts", - "model": "user" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "core", - "model": "post" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "core", - "model": "category" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "collection", - "model": "collectionrule" - } -}, -{ - "model": "sessions.session", - "pk": "3sumq22krk8tsvexcs4b8czu82yhvuer", - "fields": { - "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", - "expire_date": "2020-05-16T18:29:04.049Z" - } -}, -{ - "model": "sessions.session", - "pk": "d4wophwpjm8z96doe8iddvhdv9yfafyx", - "fields": { - "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", - "expire_date": "2020-06-07T19:45:49.727Z" - } -}, -{ - "model": "sessions.session", - "pk": "jwn66dptmdkm6hom2ns3j288aaxqtyjd", - "fields": { - "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", - "expire_date": "2020-06-07T18:38:19.116Z" - } -}, -{ - "model": "django_celery_beat.intervalschedule", - "pk": 1, - "fields": { - "every": 5, - "period": "minutes" - } -}, -{ - "model": "django_celery_beat.intervalschedule", - "pk": 2, - "fields": { - "every": 15, - "period": "minutes" - } -}, -{ - "model": "django_celery_beat.intervalschedule", - "pk": 3, - "fields": { - "every": 30, - "period": "minutes" - } -}, -{ - "model": "django_celery_beat.intervalschedule", - "pk": 4, - "fields": { - "every": 1, - "period": "hours" - } -}, -{ - "model": "django_celery_beat.intervalschedule", - "pk": 5, - "fields": { - "every": 4, - "period": "hours" - } -}, -{ - "model": "django_celery_beat.crontabschedule", - "pk": 1, - "fields": { - "minute": "0", - "hour": "4", - "day_of_week": "*", - "day_of_month": "*", - "month_of_year": "*", - "timezone": "UTC" - } -}, -{ - "model": "django_celery_beat.periodictasks", - "pk": 1, - "fields": { - "last_update": "2020-05-24T19:46:50.243Z" - } -}, -{ - "model": "django_celery_beat.periodictask", - "pk": 1, - "fields": { - "name": "celery.backend_cleanup", - "task": "celery.backend_cleanup", - "interval": null, - "crontab": 1, - "solar": null, - "clocked": null, - "args": "[]", - "kwargs": "{}", - "queue": null, - "exchange": null, - "routing_key": null, - "headers": "{}", - "priority": null, - "expires": null, - "expire_seconds": 43200, - "one_off": false, - "start_time": null, - "enabled": true, - "last_run_at": null, - "total_run_count": 0, - "date_changed": "2020-05-02T20:06:23.985Z", - "description": "" - } -}, -{ - "model": "django_celery_beat.periodictask", - "pk": 10, - "fields": { - "name": "sonny@bakker.nl-collection-task", - "task": "newsreader.news.collection.tasks.FeedTask", - "interval": 5, - "crontab": null, - "solar": null, - "clocked": null, - "args": "[1]", - "kwargs": "{}", - "queue": null, - "exchange": null, - "routing_key": null, - "headers": "{}", - "priority": null, - "expires": null, - "expire_seconds": null, - "one_off": false, - "start_time": null, - "enabled": true, - "last_run_at": "2020-05-24T18:37:57.707Z", - "total_run_count": 293, - "date_changed": "2020-05-24T19:46:50.245Z", - "description": "" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add log entry", - "content_type": [ - "admin", - "logentry" - ], - "codename": "add_logentry" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change log entry", - "content_type": [ - "admin", - "logentry" - ], - "codename": "change_logentry" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete log entry", - "content_type": [ - "admin", - "logentry" - ], - "codename": "delete_logentry" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view log entry", - "content_type": [ - "admin", - "logentry" - ], - "codename": "view_logentry" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add permission", - "content_type": [ - "auth", - "permission" - ], - "codename": "add_permission" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change permission", - "content_type": [ - "auth", - "permission" - ], - "codename": "change_permission" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete permission", - "content_type": [ - "auth", - "permission" - ], - "codename": "delete_permission" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view permission", - "content_type": [ - "auth", - "permission" - ], - "codename": "view_permission" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add group", - "content_type": [ - "auth", - "group" - ], - "codename": "add_group" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change group", - "content_type": [ - "auth", - "group" - ], - "codename": "change_group" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete group", - "content_type": [ - "auth", - "group" - ], - "codename": "delete_group" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view group", - "content_type": [ - "auth", - "group" - ], - "codename": "view_group" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add content type", - "content_type": [ - "contenttypes", - "contenttype" - ], - "codename": "add_contenttype" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change content type", - "content_type": [ - "contenttypes", - "contenttype" - ], - "codename": "change_contenttype" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete content type", - "content_type": [ - "contenttypes", - "contenttype" - ], - "codename": "delete_contenttype" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view content type", - "content_type": [ - "contenttypes", - "contenttype" - ], - "codename": "view_contenttype" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add session", - "content_type": [ - "sessions", - "session" - ], - "codename": "add_session" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change session", - "content_type": [ - "sessions", - "session" - ], - "codename": "change_session" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete session", - "content_type": [ - "sessions", - "session" - ], - "codename": "delete_session" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view session", - "content_type": [ - "sessions", - "session" - ], - "codename": "view_session" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add crontab", - "content_type": [ - "django_celery_beat", - "crontabschedule" - ], - "codename": "add_crontabschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change crontab", - "content_type": [ - "django_celery_beat", - "crontabschedule" - ], - "codename": "change_crontabschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete crontab", - "content_type": [ - "django_celery_beat", - "crontabschedule" - ], - "codename": "delete_crontabschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view crontab", - "content_type": [ - "django_celery_beat", - "crontabschedule" - ], - "codename": "view_crontabschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add interval", - "content_type": [ - "django_celery_beat", - "intervalschedule" - ], - "codename": "add_intervalschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change interval", - "content_type": [ - "django_celery_beat", - "intervalschedule" - ], - "codename": "change_intervalschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete interval", - "content_type": [ - "django_celery_beat", - "intervalschedule" - ], - "codename": "delete_intervalschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view interval", - "content_type": [ - "django_celery_beat", - "intervalschedule" - ], - "codename": "view_intervalschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add periodic task", - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "codename": "add_periodictask" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change periodic task", - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "codename": "change_periodictask" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete periodic task", - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "codename": "delete_periodictask" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view periodic task", - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "codename": "view_periodictask" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add periodic tasks", - "content_type": [ - "django_celery_beat", - "periodictasks" - ], - "codename": "add_periodictasks" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change periodic tasks", - "content_type": [ - "django_celery_beat", - "periodictasks" - ], - "codename": "change_periodictasks" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete periodic tasks", - "content_type": [ - "django_celery_beat", - "periodictasks" - ], - "codename": "delete_periodictasks" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view periodic tasks", - "content_type": [ - "django_celery_beat", - "periodictasks" - ], - "codename": "view_periodictasks" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add solar event", - "content_type": [ - "django_celery_beat", - "solarschedule" - ], - "codename": "add_solarschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change solar event", - "content_type": [ - "django_celery_beat", - "solarschedule" - ], - "codename": "change_solarschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete solar event", - "content_type": [ - "django_celery_beat", - "solarschedule" - ], - "codename": "delete_solarschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view solar event", - "content_type": [ - "django_celery_beat", - "solarschedule" - ], - "codename": "view_solarschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add clocked", - "content_type": [ - "django_celery_beat", - "clockedschedule" - ], - "codename": "add_clockedschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change clocked", - "content_type": [ - "django_celery_beat", - "clockedschedule" - ], - "codename": "change_clockedschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete clocked", - "content_type": [ - "django_celery_beat", - "clockedschedule" - ], - "codename": "delete_clockedschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view clocked", - "content_type": [ - "django_celery_beat", - "clockedschedule" - ], - "codename": "view_clockedschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add registration profile", - "content_type": [ - "registration", - "registrationprofile" - ], - "codename": "add_registrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change registration profile", - "content_type": [ - "registration", - "registrationprofile" - ], - "codename": "change_registrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete registration profile", - "content_type": [ - "registration", - "registrationprofile" - ], - "codename": "delete_registrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view registration profile", - "content_type": [ - "registration", - "registrationprofile" - ], - "codename": "view_registrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add supervised registration profile", - "content_type": [ - "registration", - "supervisedregistrationprofile" - ], - "codename": "add_supervisedregistrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change supervised registration profile", - "content_type": [ - "registration", - "supervisedregistrationprofile" - ], - "codename": "change_supervisedregistrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete supervised registration profile", - "content_type": [ - "registration", - "supervisedregistrationprofile" - ], - "codename": "delete_supervisedregistrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view supervised registration profile", - "content_type": [ - "registration", - "supervisedregistrationprofile" - ], - "codename": "view_supervisedregistrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add access attempt", - "content_type": [ - "axes", - "accessattempt" - ], - "codename": "add_accessattempt" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change access attempt", - "content_type": [ - "axes", - "accessattempt" - ], - "codename": "change_accessattempt" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete access attempt", - "content_type": [ - "axes", - "accessattempt" - ], - "codename": "delete_accessattempt" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view access attempt", - "content_type": [ - "axes", - "accessattempt" - ], - "codename": "view_accessattempt" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add access log", - "content_type": [ - "axes", - "accesslog" - ], - "codename": "add_accesslog" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change access log", - "content_type": [ - "axes", - "accesslog" - ], - "codename": "change_accesslog" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete access log", - "content_type": [ - "axes", - "accesslog" - ], - "codename": "delete_accesslog" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view access log", - "content_type": [ - "axes", - "accesslog" - ], - "codename": "view_accesslog" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add user", - "content_type": [ - "accounts", - "user" - ], - "codename": "add_user" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change user", - "content_type": [ - "accounts", - "user" - ], - "codename": "change_user" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete user", - "content_type": [ - "accounts", - "user" - ], - "codename": "delete_user" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view user", - "content_type": [ - "accounts", - "user" - ], - "codename": "view_user" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add post", - "content_type": [ - "core", - "post" - ], - "codename": "add_post" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change post", - "content_type": [ - "core", - "post" - ], - "codename": "change_post" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete post", - "content_type": [ - "core", - "post" - ], - "codename": "delete_post" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view post", - "content_type": [ - "core", - "post" - ], - "codename": "view_post" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add Category", - "content_type": [ - "core", - "category" - ], - "codename": "add_category" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change Category", - "content_type": [ - "core", - "category" - ], - "codename": "change_category" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete Category", - "content_type": [ - "core", - "category" - ], - "codename": "delete_category" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view Category", - "content_type": [ - "core", - "category" - ], - "codename": "view_category" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add collection rule", - "content_type": [ - "collection", - "collectionrule" - ], - "codename": "add_collectionrule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change collection rule", - "content_type": [ - "collection", - "collectionrule" - ], - "codename": "change_collectionrule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete collection rule", - "content_type": [ - "collection", - "collectionrule" - ], - "codename": "delete_collectionrule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view collection rule", - "content_type": [ - "collection", - "collectionrule" - ], - "codename": "view_collectionrule" - } -}, -{ - "model": "accounts.user", - "fields": { - "password": "pbkdf2_sha256$180000$KGKGsPnSwyiN$RqQAD46r4Kzqndqp5dmpj+H/drDrPRI0r6j4gLtYBjE=", - "last_login": "2020-05-24T19:45:49.721Z", - "is_superuser": true, - "first_name": "", - "last_name": "", - "is_staff": true, - "is_active": true, - "date_joined": "2019-07-18T18:52:36.080Z", - "email": "sonny@bakker.nl", - "task": 10, - "groups": [], - "user_permissions": [] - } -}, -{ - "model": "core.category", - "pk": 8, - "fields": { - "created": "2019-11-17T19:37:24.671Z", - "modified": "2019-11-18T19:59:55.010Z", - "name": "World news", - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "core.category", - "pk": 9, - "fields": { - "created": "2019-11-17T19:37:26.161Z", - "modified": "2019-11-18T19:59:45.010Z", - "name": "Tech", - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 3, - "fields": { - "created": "2019-07-14T13:08:10.374Z", - "modified": "2020-05-02T20:06:25.841Z", - "name": "Hackers News", - "url": "https://news.ycombinator.com/rss", - "website_url": "https://news.ycombinator.com/", - "favicon": "https://news.ycombinator.com/favicon.ico", - "timezone": "UTC", - "category": 9, - "last_suceeded": "2020-05-02T20:06:25.793Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 4, - "fields": { - "created": "2019-07-20T11:24:32.745Z", - "modified": "2020-05-02T20:06:24.719Z", - "name": "BBC", - "url": "http://feeds.bbci.co.uk/news/world/rss.xml", - "website_url": "https://www.bbc.co.uk/news/", - "favicon": "https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png", - "timezone": "UTC", - "category": 8, - "last_suceeded": "2020-05-02T20:06:24.128Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 5, - "fields": { - "created": "2019-07-20T11:24:50.411Z", - "modified": "2020-05-02T20:06:25.548Z", - "name": "Ars Technica", - "url": "http://feeds.arstechnica.com/arstechnica/index?fmt=xml", - "website_url": "https://arstechnica.com", - "favicon": "https://cdn.arstechnica.net/favicon.ico", - "timezone": "UTC", - "category": 9, - "last_suceeded": "2020-05-02T20:06:25.364Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 6, - "fields": { - "created": "2019-07-20T11:25:02.089Z", - "modified": "2020-05-02T20:06:25.741Z", - "name": "The Guardian", - "url": "https://www.theguardian.com/world/rss", - "website_url": "https://www.theguardian.com/world", - "favicon": "https://assets.guim.co.uk/images/favicons/873381bf11d58e20f551905d51575117/72x72.png", - "timezone": "UTC", - "category": 8, - "last_suceeded": "2020-05-02T20:06:25.620Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 7, - "fields": { - "created": "2019-07-20T11:25:30.121Z", - "modified": "2020-05-02T20:06:25.352Z", - "name": "Tweakers", - "url": "http://feeds.feedburner.com/tweakers/mixed?fmt=xml", - "website_url": "https://tweakers.net/", - "favicon": null, - "timezone": "UTC", - "category": 9, - "last_suceeded": "2020-05-02T20:06:24.730Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 8, - "fields": { - "created": "2019-07-20T11:25:46.256Z", - "modified": "2020-05-02T20:06:25.792Z", - "name": "The Verge", - "url": "https://www.theverge.com/rss/index.xml", - "website_url": "https://www.theverge.com/", - "favicon": "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395367/favicon-16x16.0.png", - "timezone": "UTC", - "category": 9, - "last_suceeded": "2020-05-02T20:06:25.742Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 9, - "fields": { - "created": "2019-11-24T15:28:41.399Z", - "modified": "2020-05-02T20:06:25.619Z", - "name": "NOS", - "url": "http://feeds.nos.nl/nosnieuwsalgemeen", - "website_url": null, - "favicon": null, - "timezone": "Europe/Amsterdam", - "category": 8, - "last_suceeded": "2020-05-02T20:06:25.549Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 10, - "fields": { - "created": "2020-05-02T20:32:34.107Z", - "modified": "2020-05-02T20:32:34.107Z", - "name": "CollectionRule-0", - "url": "http://rasmussen-guerra.com/", - "website_url": "https://ritter.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 11, - "fields": { - "created": "2020-05-02T20:32:34.164Z", - "modified": "2020-05-02T20:32:34.164Z", - "name": "CollectionRule-1", - "url": "https://www.evans.com/", - "website_url": "https://taylor.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 12, - "fields": { - "created": "2020-05-02T20:32:34.220Z", - "modified": "2020-05-02T20:32:34.220Z", - "name": "CollectionRule-2", - "url": "http://weaver-quinn.net/", - "website_url": "https://www.mcintyre.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 13, - "fields": { - "created": "2020-05-02T20:32:34.277Z", - "modified": "2020-05-02T20:32:34.277Z", - "name": "CollectionRule-3", - "url": "http://www.palmer.com/", - "website_url": "http://www.riggs.org/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 14, - "fields": { - "created": "2020-05-02T20:32:34.333Z", - "modified": "2020-05-02T20:32:34.333Z", - "name": "CollectionRule-4", - "url": "http://moody-stein.net/", - "website_url": "https://www.lewis.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 15, - "fields": { - "created": "2020-05-02T20:32:34.390Z", - "modified": "2020-05-02T20:32:34.391Z", - "name": "CollectionRule-5", - "url": "http://www.ochoa.com/", - "website_url": "https://brown.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 16, - "fields": { - "created": "2020-05-02T20:32:34.448Z", - "modified": "2020-05-02T20:32:34.448Z", - "name": "CollectionRule-6", - "url": "https://www.pearson.biz/", - "website_url": "http://acosta-johnson.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 17, - "fields": { - "created": "2020-05-02T20:32:34.506Z", - "modified": "2020-05-02T20:32:34.506Z", - "name": "CollectionRule-7", - "url": "https://jones.com/", - "website_url": "https://www.thornton.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 18, - "fields": { - "created": "2020-05-02T20:32:34.562Z", - "modified": "2020-05-02T20:32:34.562Z", - "name": "CollectionRule-8", - "url": "http://www.matthews-graves.com/", - "website_url": "http://stewart.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 19, - "fields": { - "created": "2020-05-02T20:32:34.618Z", - "modified": "2020-05-02T20:32:34.618Z", - "name": "CollectionRule-9", - "url": "http://www.kelly-martinez.com/", - "website_url": "https://www.freeman.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 20, - "fields": { - "created": "2020-05-02T20:32:34.674Z", - "modified": "2020-05-02T20:32:34.674Z", - "name": "CollectionRule-10", - "url": "https://www.roberts.biz/", - "website_url": "http://www.lopez.info/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 21, - "fields": { - "created": "2020-05-02T20:32:34.730Z", - "modified": "2020-05-02T20:32:34.730Z", - "name": "CollectionRule-11", - "url": "https://www.holmes-cross.com/", - "website_url": "https://www.ramirez.net/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 22, - "fields": { - "created": "2020-05-02T20:32:34.786Z", - "modified": "2020-05-02T20:32:34.786Z", - "name": "CollectionRule-12", - "url": "https://www.jenkins.com/", - "website_url": "https://www.faulkner.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 23, - "fields": { - "created": "2020-05-02T20:32:34.841Z", - "modified": "2020-05-02T20:32:34.842Z", - "name": "CollectionRule-13", - "url": "https://www.adkins.com/", - "website_url": "https://www.munoz-brown.info/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 24, - "fields": { - "created": "2020-05-02T20:32:34.897Z", - "modified": "2020-05-02T20:32:34.898Z", - "name": "CollectionRule-14", - "url": "https://www.rodriguez-ortega.biz/", - "website_url": "http://www.santos.info/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 25, - "fields": { - "created": "2020-05-02T20:32:34.953Z", - "modified": "2020-05-02T20:32:34.954Z", - "name": "CollectionRule-15", - "url": "https://www.hawkins-stewart.com/", - "website_url": "http://www.jones.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 26, - "fields": { - "created": "2020-05-02T20:32:35.010Z", - "modified": "2020-05-02T20:32:35.010Z", - "name": "CollectionRule-16", - "url": "http://mullins.net/", - "website_url": "https://www.curtis.org/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 27, - "fields": { - "created": "2020-05-02T20:32:35.067Z", - "modified": "2020-05-02T20:32:35.067Z", - "name": "CollectionRule-17", - "url": "http://frederick.com/", - "website_url": "https://www.fowler.info/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 28, - "fields": { - "created": "2020-05-02T20:32:35.124Z", - "modified": "2020-05-02T20:32:35.124Z", - "name": "CollectionRule-18", - "url": "http://schmidt.com/", - "website_url": "http://bryant-hoffman.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 29, - "fields": { - "created": "2020-05-02T20:32:35.180Z", - "modified": "2020-05-02T20:32:35.180Z", - "name": "CollectionRule-19", - "url": "https://www.jones.net/", - "website_url": "http://benjamin.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 30, - "fields": { - "created": "2020-05-02T20:32:35.237Z", - "modified": "2020-05-02T20:32:35.237Z", - "name": "CollectionRule-20", - "url": "https://www.parker-lewis.com/", - "website_url": "http://www.anderson.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 31, - "fields": { - "created": "2020-05-02T20:32:35.294Z", - "modified": "2020-05-02T20:32:35.294Z", - "name": "CollectionRule-21", - "url": "http://martinez.com/", - "website_url": "http://burton-scott.biz/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 32, - "fields": { - "created": "2020-05-02T20:32:35.350Z", - "modified": "2020-05-02T20:32:35.350Z", - "name": "CollectionRule-22", - "url": "https://gibbs.com/", - "website_url": "https://www.robertson.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 33, - "fields": { - "created": "2020-05-02T20:32:35.407Z", - "modified": "2020-05-02T20:32:35.407Z", - "name": "CollectionRule-23", - "url": "http://www.fisher.com/", - "website_url": "https://mcclure-miller.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 34, - "fields": { - "created": "2020-05-02T20:32:35.463Z", - "modified": "2020-05-02T20:32:35.463Z", - "name": "CollectionRule-24", - "url": "https://schneider-lopez.org/", - "website_url": "https://andrews-williams.biz/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 35, - "fields": { - "created": "2020-05-02T20:32:35.522Z", - "modified": "2020-05-02T20:32:35.522Z", - "name": "CollectionRule-25", - "url": "http://www.rogers.info/", - "website_url": "https://www.petersen-stewart.biz/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 36, - "fields": { - "created": "2020-05-02T20:32:35.581Z", - "modified": "2020-05-02T20:32:35.581Z", - "name": "CollectionRule-26", - "url": "http://torres.com/", - "website_url": "https://hart-tapia.org/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 37, - "fields": { - "created": "2020-05-02T20:32:35.637Z", - "modified": "2020-05-02T20:32:35.638Z", - "name": "CollectionRule-27", - "url": "http://www.pham-scott.com/", - "website_url": "http://smith-diaz.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 38, - "fields": { - "created": "2020-05-02T20:32:35.699Z", - "modified": "2020-05-02T20:32:35.699Z", - "name": "CollectionRule-28", - "url": "http://www.gonzalez-castillo.com/", - "website_url": "http://www.conley.biz/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 39, - "fields": { - "created": "2020-05-02T20:32:35.758Z", - "modified": "2020-05-02T20:32:35.758Z", - "name": "CollectionRule-29", - "url": "https://rogers-smith.net/", - "website_url": "http://www.sharp.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 40, - "fields": { - "created": "2020-05-02T20:32:35.814Z", - "modified": "2020-05-02T20:32:35.814Z", - "name": "CollectionRule-30", - "url": "https://neal-salinas.com/", - "website_url": "https://www.baird-warner.net/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 41, - "fields": { - "created": "2020-05-02T20:32:35.873Z", - "modified": "2020-05-02T20:32:35.874Z", - "name": "CollectionRule-31", - "url": "http://www.williams.com/", - "website_url": "http://www.wood.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 42, - "fields": { - "created": "2020-05-02T20:32:35.930Z", - "modified": "2020-05-02T20:32:35.930Z", - "name": "CollectionRule-32", - "url": "https://www.mueller.com/", - "website_url": "http://www.miller-ramirez.org/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 43, - "fields": { - "created": "2020-05-02T20:32:35.988Z", - "modified": "2020-05-02T20:32:35.989Z", - "name": "CollectionRule-33", - "url": "http://lee.com/", - "website_url": "http://www.moody.org/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 44, - "fields": { - "created": "2020-05-02T20:32:36.044Z", - "modified": "2020-05-02T20:32:36.045Z", - "name": "CollectionRule-34", - "url": "http://estrada.com/", - "website_url": "http://www.hicks.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 45, - "fields": { - "created": "2020-05-02T20:32:36.102Z", - "modified": "2020-05-02T20:32:36.102Z", - "name": "CollectionRule-35", - "url": "https://griffin-brewer.org/", - "website_url": "http://jones.info/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 46, - "fields": { - "created": "2020-05-02T20:32:36.161Z", - "modified": "2020-05-02T20:32:36.161Z", - "name": "CollectionRule-36", - "url": "http://www.dixon-johnson.com/", - "website_url": "https://mason.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 47, - "fields": { - "created": "2020-05-02T20:32:36.217Z", - "modified": "2020-05-02T20:32:36.217Z", - "name": "CollectionRule-37", - "url": "https://perez.com/", - "website_url": "http://www.miller.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 48, - "fields": { - "created": "2020-05-02T20:32:36.278Z", - "modified": "2020-05-02T20:32:36.279Z", - "name": "CollectionRule-38", - "url": "https://www.grant.net/", - "website_url": "https://www.clayton.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 49, - "fields": { - "created": "2020-05-02T20:32:36.336Z", - "modified": "2020-05-02T20:32:36.336Z", - "name": "CollectionRule-39", - "url": "http://www.lewis.org/", - "website_url": "http://cook.org/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 50, - "fields": { - "created": "2020-05-02T20:32:36.395Z", - "modified": "2020-05-02T20:32:36.395Z", - "name": "CollectionRule-40", - "url": "https://galloway-allen.net/", - "website_url": "http://www.rodriguez-callahan.info/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 51, - "fields": { - "created": "2020-05-02T20:32:36.453Z", - "modified": "2020-05-02T20:32:36.453Z", - "name": "CollectionRule-41", - "url": "https://www.macias.com/", - "website_url": "https://jarvis-green.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 52, - "fields": { - "created": "2020-05-02T20:32:36.510Z", - "modified": "2020-05-02T20:32:36.510Z", - "name": "CollectionRule-42", - "url": "http://mccullough-grant.com/", - "website_url": "https://shannon.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 53, - "fields": { - "created": "2020-05-02T20:32:36.566Z", - "modified": "2020-05-02T20:32:36.566Z", - "name": "CollectionRule-43", - "url": "http://www.foster-oneal.org/", - "website_url": "http://johns.org/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 54, - "fields": { - "created": "2020-05-02T20:32:36.623Z", - "modified": "2020-05-02T20:32:36.623Z", - "name": "CollectionRule-44", - "url": "http://www.wright.net/", - "website_url": "http://www.ali.biz/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 55, - "fields": { - "created": "2020-05-02T20:32:36.682Z", - "modified": "2020-05-02T20:32:36.682Z", - "name": "CollectionRule-45", - "url": "http://www.payne-gibbs.info/", - "website_url": "http://knight.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 56, - "fields": { - "created": "2020-05-02T20:32:36.740Z", - "modified": "2020-05-02T20:32:36.740Z", - "name": "CollectionRule-46", - "url": "http://hammond.biz/", - "website_url": "http://www.nelson.net/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 57, - "fields": { - "created": "2020-05-02T20:32:36.797Z", - "modified": "2020-05-02T20:32:36.797Z", - "name": "CollectionRule-47", - "url": "http://gilmore.com/", - "website_url": "http://coleman.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 58, - "fields": { - "created": "2020-05-02T20:32:36.855Z", - "modified": "2020-05-02T20:32:36.855Z", - "name": "CollectionRule-48", - "url": "https://www.hernandez.com/", - "website_url": "https://www.phillips.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 59, - "fields": { - "created": "2020-05-02T20:32:36.912Z", - "modified": "2020-05-02T20:32:36.912Z", - "name": "CollectionRule-49", - "url": "https://www.nguyen.com/", - "website_url": "http://www.floyd.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 60, - "fields": { - "created": "2020-05-02T20:32:36.969Z", - "modified": "2020-05-02T20:32:36.969Z", - "name": "CollectionRule-50", - "url": "https://meyer-brown.net/", - "website_url": "https://www.blankenship.biz/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 61, - "fields": { - "created": "2020-05-02T20:32:37.026Z", - "modified": "2020-05-02T20:32:37.027Z", - "name": "CollectionRule-51", - "url": "https://marks.net/", - "website_url": "http://gregory.net/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 62, - "fields": { - "created": "2020-05-02T20:32:37.087Z", - "modified": "2020-05-02T20:32:37.087Z", - "name": "CollectionRule-52", - "url": "http://www.baxter.com/", - "website_url": "http://barrera.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 63, - "fields": { - "created": "2020-05-02T20:32:37.143Z", - "modified": "2020-05-02T20:32:37.143Z", - "name": "CollectionRule-53", - "url": "http://johnson.com/", - "website_url": "https://abbott.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 64, - "fields": { - "created": "2020-05-02T20:32:37.202Z", - "modified": "2020-05-02T20:32:37.202Z", - "name": "CollectionRule-54", - "url": "https://hebert-marshall.biz/", - "website_url": "https://www.ashley-walsh.org/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 65, - "fields": { - "created": "2020-05-02T20:32:37.261Z", - "modified": "2020-05-02T20:32:37.261Z", - "name": "CollectionRule-55", - "url": "https://miller.com/", - "website_url": "https://www.hoffman.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 66, - "fields": { - "created": "2020-05-02T20:32:37.320Z", - "modified": "2020-05-02T20:32:37.320Z", - "name": "CollectionRule-56", - "url": "http://frey.com/", - "website_url": "https://long.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 67, - "fields": { - "created": "2020-05-02T20:32:37.379Z", - "modified": "2020-05-02T20:32:37.379Z", - "name": "CollectionRule-57", - "url": "https://edwards.com/", - "website_url": "http://www.nixon-doyle.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 68, - "fields": { - "created": "2020-05-02T20:32:37.435Z", - "modified": "2020-05-02T20:32:37.435Z", - "name": "CollectionRule-58", - "url": "https://www.bennett.com/", - "website_url": "http://sullivan.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 69, - "fields": { - "created": "2020-05-02T20:32:37.493Z", - "modified": "2020-05-02T20:32:37.493Z", - "name": "CollectionRule-59", - "url": "http://stokes-thomas.com/", - "website_url": "http://morgan.net/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 70, - "fields": { - "created": "2020-05-02T20:32:37.550Z", - "modified": "2020-05-02T20:32:37.550Z", - "name": "CollectionRule-60", - "url": "https://moore.net/", - "website_url": "http://www.hubbard.biz/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 71, - "fields": { - "created": "2020-05-02T20:32:37.609Z", - "modified": "2020-05-02T20:32:37.609Z", - "name": "CollectionRule-61", - "url": "https://baker-edwards.com/", - "website_url": "https://www.anderson.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 72, - "fields": { - "created": "2020-05-02T20:32:37.666Z", - "modified": "2020-05-02T20:32:37.666Z", - "name": "CollectionRule-62", - "url": "https://www.jackson.com/", - "website_url": "https://www.edwards.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 73, - "fields": { - "created": "2020-05-02T20:32:37.724Z", - "modified": "2020-05-02T20:32:37.724Z", - "name": "CollectionRule-63", - "url": "https://kemp-pollard.biz/", - "website_url": "http://www.fuentes.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 74, - "fields": { - "created": "2020-05-02T20:32:37.782Z", - "modified": "2020-05-02T20:32:37.782Z", - "name": "CollectionRule-64", - "url": "https://hanna-cook.com/", - "website_url": "http://www.bowen.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 75, - "fields": { - "created": "2020-05-02T20:32:37.839Z", - "modified": "2020-05-02T20:32:37.839Z", - "name": "CollectionRule-65", - "url": "http://www.williams.net/", - "website_url": "http://www.chandler.org/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 76, - "fields": { - "created": "2020-05-02T20:32:37.896Z", - "modified": "2020-05-02T20:32:37.896Z", - "name": "CollectionRule-66", - "url": "https://www.alexander.com/", - "website_url": "https://johnson-ellis.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 77, - "fields": { - "created": "2020-05-02T20:32:37.951Z", - "modified": "2020-05-02T20:32:37.951Z", - "name": "CollectionRule-67", - "url": "https://www.cisneros.com/", - "website_url": "http://fox.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 78, - "fields": { - "created": "2020-05-02T20:32:38.008Z", - "modified": "2020-05-02T20:32:38.008Z", - "name": "CollectionRule-68", - "url": "http://www.foster-burton.com/", - "website_url": "https://grant.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 79, - "fields": { - "created": "2020-05-02T20:32:38.066Z", - "modified": "2020-05-02T20:32:38.066Z", - "name": "CollectionRule-69", - "url": "https://www.hayes.net/", - "website_url": "http://morgan.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "admin.logentry", - "pk": 1, - "fields": { - "action_time": "2020-05-24T18:38:44.624Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "django_celery_beat", - "intervalschedule" - ], - "object_id": "5", - "object_repr": "every 4 hours", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 2, - "fields": { - "action_time": "2020-05-24T18:38:46.689Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "object_id": "10", - "object_repr": "sonny@bakker.nl-collection-task: every 4 hours", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Interval Schedule\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 3, - "fields": { - "action_time": "2020-05-24T18:39:09.203Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "object_id": "26", - "object_repr": "sonnyba871@gmail.com-collection-task: every hour", - "action_flag": 3, - "change_message": "" - } -}, -{ - "model": "admin.logentry", - "pk": 4, - "fields": { - "action_time": "2020-05-24T19:46:50.248Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "object_id": "10", - "object_repr": "sonny@bakker.nl-collection-task: every 4 hours", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Positional Arguments\"]}}]" - } -} -] +[ +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "admin", + "model": "logentry" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "auth", + "model": "permission" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "auth", + "model": "group" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "contenttypes", + "model": "contenttype" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "sessions", + "model": "session" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "crontabschedule" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "intervalschedule" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "periodictask" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "periodictasks" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "solarschedule" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "clockedschedule" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "registration", + "model": "registrationprofile" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "registration", + "model": "supervisedregistrationprofile" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "axes", + "model": "accessattempt" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "axes", + "model": "accesslog" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "accounts", + "model": "user" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "core", + "model": "post" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "core", + "model": "category" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "collection", + "model": "collectionrule" + } +}, +{ + "model": "sessions.session", + "pk": "3sumq22krk8tsvexcs4b8czu82yhvuer", + "fields": { + "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", + "expire_date": "2020-05-16T18:29:04.049Z" + } +}, +{ + "model": "sessions.session", + "pk": "8ix6bdwf2ywk0eir1hb062dhfh9xit85", + "fields": { + "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", + "expire_date": "2020-07-21T19:36:54.530Z" + } +}, +{ + "model": "sessions.session", + "pk": "d4wophwpjm8z96doe8iddvhdv9yfafyx", + "fields": { + "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", + "expire_date": "2020-06-07T19:45:49.727Z" + } +}, +{ + "model": "sessions.session", + "pk": "g23ziz66li5zx8nd8cewb3vevdxhjkm0", + "fields": { + "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", + "expire_date": "2020-06-30T06:55:50.747Z" + } +}, +{ + "model": "sessions.session", + "pk": "jwn66dptmdkm6hom2ns3j288aaxqtyjd", + "fields": { + "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", + "expire_date": "2020-06-07T18:38:19.116Z" + } +}, +{ + "model": "sessions.session", + "pk": "wjz6kwg5e5ciemre0l0wwyrcwcj2gyg6", + "fields": { + "session_data": "MWU5ODBjY2QyOTFhMmRiY2QyYjQwZjQ3MmMwYmExYjBlYTkxNTcwODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiI0YWZkYTkxNzU5ZDBhZDZmMjg1ZTQyOGY0OTUxN2M5MTJhMmM5NWIyIn0=", + "expire_date": "2020-08-09T09:52:04.705Z" + } +}, +{ + "model": "django_celery_beat.intervalschedule", + "pk": 1, + "fields": { + "every": 5, + "period": "minutes" + } +}, +{ + "model": "django_celery_beat.intervalschedule", + "pk": 2, + "fields": { + "every": 15, + "period": "minutes" + } +}, +{ + "model": "django_celery_beat.intervalschedule", + "pk": 3, + "fields": { + "every": 30, + "period": "minutes" + } +}, +{ + "model": "django_celery_beat.intervalschedule", + "pk": 4, + "fields": { + "every": 1, + "period": "hours" + } +}, +{ + "model": "django_celery_beat.intervalschedule", + "pk": 5, + "fields": { + "every": 4, + "period": "hours" + } +}, +{ + "model": "django_celery_beat.crontabschedule", + "pk": 1, + "fields": { + "minute": "0", + "hour": "4", + "day_of_week": "*", + "day_of_month": "*", + "month_of_year": "*", + "timezone": "UTC" + } +}, +{ + "model": "django_celery_beat.periodictasks", + "pk": 1, + "fields": { + "last_update": "2020-07-26T09:47:48.298Z" + } +}, +{ + "model": "django_celery_beat.periodictask", + "pk": 1, + "fields": { + "name": "celery.backend_cleanup", + "task": "celery.backend_cleanup", + "interval": null, + "crontab": 1, + "solar": null, + "clocked": null, + "args": "[]", + "kwargs": "{}", + "queue": null, + "exchange": null, + "routing_key": null, + "headers": "{}", + "priority": null, + "expires": null, + "expire_seconds": 43200, + "one_off": false, + "start_time": null, + "enabled": true, + "last_run_at": "2020-07-26T09:47:48.322Z", + "total_run_count": 17, + "date_changed": "2020-07-26T09:47:50.362Z", + "description": "" + } +}, +{ + "model": "django_celery_beat.periodictask", + "pk": 10, + "fields": { + "name": "sonny@bakker.nl-collection-task", + "task": "FeedTask", + "interval": 5, + "crontab": null, + "solar": null, + "clocked": null, + "args": "[1]", + "kwargs": "{}", + "queue": null, + "exchange": null, + "routing_key": null, + "headers": "{}", + "priority": null, + "expires": null, + "expire_seconds": null, + "one_off": false, + "start_time": null, + "enabled": false, + "last_run_at": "2020-07-14T11:45:26.209Z", + "total_run_count": 307, + "date_changed": "2020-07-14T11:45:41.282Z", + "description": "" + } +}, +{ + "model": "django_celery_beat.periodictask", + "pk": 11, + "fields": { + "name": "Reddit collection task", + "task": "RedditTask", + "interval": 5, + "crontab": null, + "solar": null, + "clocked": null, + "args": "[]", + "kwargs": "{}", + "queue": null, + "exchange": null, + "routing_key": null, + "headers": "{}", + "priority": null, + "expires": null, + "expire_seconds": null, + "one_off": false, + "start_time": null, + "enabled": false, + "last_run_at": null, + "total_run_count": 4, + "date_changed": "2020-07-14T11:45:41.316Z", + "description": "" + } +}, +{ + "model": "core.post", + "pk": 3061, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:14:50.423Z", + "title": "Star Citizen: Question and Answer Thread", + "body": "

      Welcome to the Star Citizen question and answer thread. Feel free to ask any questions you have related to SC here!

      \n\n\n\n

      Useful Links and Resources:

      \n\n

      Star Citizen Wiki - The biggest and best wiki resource dedicated to Star Citizen

      \n\n

      Star Citizen FAQ - Chances the answer you need is here.

      \n\n

      Discord Help Channel - Often times community members will be here to help you with issues.

      \n\n

      Referral Code Randomizer - Use this when creating a new account to get 5000 extra UEC.

      \n\n

      Download Star Citizen - Get the latest version of Star Citizen here

      \n\n

      Current Game Features - Click here to see what you can currently do in Star Citizen.

      \n\n

      Development Roadmap - The current development status of up and coming Star Citizen features.

      \n\n

      Pledge FAQ - Official FAQ regarding spending money on the game.

      \n
      ", + "author": "UEE_Central_Computer", + "publication_date": "2020-07-20T14:00:10Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huk04t/star_citizen_question_and_answer_thread/", + "read": false, + "rule": 82, + "remote_identifier": "huk04t" + } +}, +{ + "model": "core.post", + "pk": 3062, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:33:37.019Z", + "title": "Peace and Quiet", + "body": "
      \"Peace
      ", + "author": "SourMemeNZ", + "publication_date": "2020-07-20T14:09:49Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huk4ib/peace_and_quiet/", + "read": true, + "rule": 82, + "remote_identifier": "huk4ib" + } +}, +{ + "model": "core.post", + "pk": 3063, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:14:50.463Z", + "title": "Y'all are probably sick of em by now but here's my LEGO Mercury Star Runner (MSR).", + "body": "
      \"Y'all
      ", + "author": "osamadabinman", + "publication_date": "2020-07-20T19:53:23Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hupzqa/yall_are_probably_sick_of_em_by_now_but_heres_my/", + "read": true, + "rule": 82, + "remote_identifier": "hupzqa" + } +}, +{ + "model": "core.post", + "pk": 3064, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:17:12.253Z", + "title": "Damned Space Invaders and their pixel weapons!", + "body": "
      \"Damned
      ", + "author": "Akaradrin", + "publication_date": "2020-07-20T14:26:18Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hukckf/damned_space_invaders_and_their_pixel_weapons/", + "read": true, + "rule": 82, + "remote_identifier": "hukckf" + } +}, +{ + "model": "core.post", + "pk": 3065, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:32:35.578Z", + "title": "The sky is no longer the limit", + "body": "
      \"The
      ", + "author": "CyberTill", + "publication_date": "2020-07-20T14:11:31Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huk5b8/the_sky_is_no_longer_the_limit/", + "read": false, + "rule": 82, + "remote_identifier": "huk5b8" + } +}, +{ + "model": "core.post", + "pk": 3066, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:17:23.282Z", + "title": "Terrapin Hover Mode Gameplay [Full Video in Comments]", + "body": "
      ", + "author": "Didactic_Tomato", + "publication_date": "2020-07-20T11:01:13Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hui1gv/terrapin_hover_mode_gameplay_full_video_in/", + "read": true, + "rule": 82, + "remote_identifier": "hui1gv" + } +}, +{ + "model": "core.post", + "pk": 3067, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:17:44.250Z", + "title": "honestly", + "body": "
      \"honestly\"
      ", + "author": "Beatlead", + "publication_date": "2020-07-20T18:24:07Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huo96t/honestly/", + "read": true, + "rule": 82, + "remote_identifier": "huo96t" + } +}, +{ + "model": "core.post", + "pk": 3068, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:32:35.584Z", + "title": "As a paranoiac and tired of checking if door was closed, saved to f4 thoses \"security cam\" positions, could be usefull for larger ships :)", + "body": "", + "author": "icwiener__", + "publication_date": "2020-07-20T13:03:33Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hujchz/as_a_paranoiac_and_tired_of_checking_if_door_was/", + "read": false, + "rule": 82, + "remote_identifier": "hujchz" + } +}, +{ + "model": "core.post", + "pk": 3069, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:33:59.158Z", + "title": "Station Manager: \"You're too fat, we won't let you in, go and fall on Lorville. Thank you for your call!\" Me: \"okay :'(\"", + "body": "
      \"Station
      ", + "author": "Shaman_N_One", + "publication_date": "2020-07-20T11:33:38Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huidlu/station_manager_youre_too_fat_we_wont_let_you_in/", + "read": true, + "rule": 82, + "remote_identifier": "huidlu" + } +}, +{ + "model": "core.post", + "pk": 3070, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:32:35.588Z", + "title": "[PTU Bug Hunt Request] Packet Loss", + "body": "", + "author": "Rainwalker007", + "publication_date": "2020-07-20T18:38:03Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huoicq/ptu_bug_hunt_request_packet_loss/", + "read": false, + "rule": 82, + "remote_identifier": "huoicq" + } +}, +{ + "model": "core.post", + "pk": 3071, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:17:52.092Z", + "title": "Anyone able to explain these \"trail frames\"?", + "body": "
      \"Anyone
      ", + "author": "Abnormal_Sloth", + "publication_date": "2020-07-20T17:11:32Z", + "url": "https://www.reddit.com/r/starcitizen/comments/humyeq/anyone_able_to_explain_these_trail_frames/", + "read": true, + "rule": 82, + "remote_identifier": "humyeq" + } +}, +{ + "model": "core.post", + "pk": 3072, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:32:35.593Z", + "title": "#BringBackBugSmasher - A long forgotten legendary video content", + "body": "", + "author": "MasterBoring", + "publication_date": "2020-07-20T18:05:54Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hunx77/bringbackbugsmasher_a_long_forgotten_legendary/", + "read": false, + "rule": 82, + "remote_identifier": "hunx77" + } +}, +{ + "model": "core.post", + "pk": 3073, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:33:22.601Z", + "title": "Oracle Helmet [in-game screenshot; downsampled to 4k]", + "body": "
      \"Oracle
      ", + "author": "mr-hasgaha", + "publication_date": "2020-07-20T17:39:34Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hung0b/oracle_helmet_ingame_screenshot_downsampled_to_4k/", + "read": true, + "rule": 82, + "remote_identifier": "hung0b" + } +}, +{ + "model": "core.post", + "pk": 3074, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:34:42.578Z", + "title": "Testing 3.10 - Gladius in decoupled mode", + "body": "
      ", + "author": "DarkConstant", + "publication_date": "2020-07-19T21:26:52Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hu6f1h/testing_310_gladius_in_decoupled_mode/", + "read": true, + "rule": 82, + "remote_identifier": "hu6f1h" + } +}, +{ + "model": "core.post", + "pk": 3075, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:34:29.424Z", + "title": "Day 3, I can't stop taking pictures with my Carrack. Send help", + "body": "
      \"Day
      ", + "author": "CyberTill", + "publication_date": "2020-07-20T01:58:15Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huazyy/day_3_i_cant_stop_taking_pictures_with_my_carrack/", + "read": true, + "rule": 82, + "remote_identifier": "huazyy" + } +}, +{ + "model": "core.post", + "pk": 3076, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:32:35.602Z", + "title": "I used to enjoy flying between the buildings of new babbage, I mean before the NFZ \"improvement\"", + "body": "
      \"I
      ", + "author": "shoeii", + "publication_date": "2020-07-20T16:40:26Z", + "url": "https://www.reddit.com/r/starcitizen/comments/humet2/i_used_to_enjoy_flying_between_the_buildings_of/", + "read": false, + "rule": 82, + "remote_identifier": "humet2" + } +}, +{ + "model": "core.post", + "pk": 3077, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:18:04.237Z", + "title": "Thank you CIG for updated heightmaps and render distances", + "body": "
      \"Thank
      ", + "author": "u7f76", + "publication_date": "2020-07-19T23:38:22Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hu8pwf/thank_you_cig_for_updated_heightmaps_and_render/", + "read": true, + "rule": 82, + "remote_identifier": "hu8pwf" + } +}, +{ + "model": "core.post", + "pk": 3078, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:32:35.607Z", + "title": "This Week in Star Citizen | July 20th 2020", + "body": "", + "author": "ivtiprogamer", + "publication_date": "2020-07-20T19:50:29Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hupxnt/this_week_in_star_citizen_july_20th_2020/", + "read": false, + "rule": 82, + "remote_identifier": "hupxnt" + } +}, +{ + "model": "core.post", + "pk": 3079, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:34:36.068Z", + "title": "Bravo CIG lighting team! Noticeable improvements to all around environment lighting in 3.10", + "body": "
      \"Bravo
      ", + "author": "u7f76", + "publication_date": "2020-07-20T00:02:23Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hu94o0/bravo_cig_lighting_team_noticeable_improvements/", + "read": true, + "rule": 82, + "remote_identifier": "hu94o0" + } +}, +{ + "model": "core.post", + "pk": 3080, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:32:35.613Z", + "title": "Thick", + "body": "
      \"Thick\"
      ", + "author": "burgerbagel", + "publication_date": "2020-07-20T16:24:38Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hum50f/thick/", + "read": false, + "rule": 82, + "remote_identifier": "hum50f" + } +}, +{ + "model": "core.post", + "pk": 3081, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:34:19.763Z", + "title": "Soon\u2122", + "body": "
      \"Soon\u2122\"
      ", + "author": "Mistralette", + "publication_date": "2020-07-20T05:54:09Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hueg01/soon/", + "read": true, + "rule": 82, + "remote_identifier": "hueg01" + } +}, +{ + "model": "core.post", + "pk": 3082, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:32:35.618Z", + "title": "On the prowl", + "body": "
      \"On
      ", + "author": "SaraCaterina", + "publication_date": "2020-07-20T16:37:03Z", + "url": "https://www.reddit.com/r/starcitizen/comments/humcmb/on_the_prowl/", + "read": false, + "rule": 82, + "remote_identifier": "humcmb" + } +}, +{ + "model": "core.post", + "pk": 3083, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:34:07.272Z", + "title": "The Hills Have Eyes", + "body": "
      \"The
      ", + "author": "FallenLordik", + "publication_date": "2020-07-20T11:19:19Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hui8ao/the_hills_have_eyes/", + "read": true, + "rule": 82, + "remote_identifier": "hui8ao" + } +}, +{ + "model": "core.post", + "pk": 3084, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:32:35.623Z", + "title": "Worried about longer loading screens? Hit ~ and do r_displayinfo 3", + "body": "
      \"Worried
      ", + "author": "kristokn", + "publication_date": "2020-07-20T10:09:53Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huhif1/worried_about_longer_loading_screens_hit_and_do_r/", + "read": false, + "rule": 82, + "remote_identifier": "huhif1" + } +}, +{ + "model": "core.post", + "pk": 3085, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:32:35.625Z", + "title": "My contribution to the wallpaper contest... click for the full effect (3440x1440)", + "body": "
      \"My
      ", + "author": "Dougie_Juice", + "publication_date": "2020-07-20T20:02:31Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huq655/my_contribution_to_the_wallpaper_contest_click/", + "read": false, + "rule": 82, + "remote_identifier": "huq655" + } +}, +{ + "model": "core.post", + "pk": 3086, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:32:35.627Z", + "title": "Star Citizen: The Onion (Parody Project)", + "body": "", + "author": "BroadOne", + "publication_date": "2020-07-20T19:19:20Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hupbkj/star_citizen_the_onion_parody_project/", + "read": false, + "rule": 82, + "remote_identifier": "hupbkj" + } +}, +{ + "model": "core.post", + "pk": 3087, + "fields": { + "created": "2020-07-20T19:32:35.635Z", + "modified": "2020-07-20T19:32:35.637Z", + "title": "perfect day to sunbathe", + "body": "
      ", + "author": "Pedrica1", + "publication_date": "2020-07-20T18:08:17Z", + "url": "https://www.reddit.com/r/aww/comments/hunysb/perfect_day_to_sunbathe/", + "read": false, + "rule": 81, + "remote_identifier": "hunysb" + } +}, +{ + "model": "core.post", + "pk": 3088, + "fields": { + "created": "2020-07-20T19:32:35.635Z", + "modified": "2020-07-20T19:32:35.639Z", + "title": "My dogs face when he sees I'm home", + "body": "
      ", + "author": "NewReddit_WhoDis", + "publication_date": "2020-07-20T16:45:21Z", + "url": "https://www.reddit.com/r/aww/comments/humhxa/my_dogs_face_when_he_sees_im_home/", + "read": false, + "rule": 81, + "remote_identifier": "humhxa" + } +}, +{ + "model": "core.post", + "pk": 3089, + "fields": { + "created": "2020-07-20T19:32:35.635Z", + "modified": "2020-07-20T19:32:35.641Z", + "title": "Cow loves the scritch machine", + "body": "
      ", + "author": "Der_Ist", + "publication_date": "2020-07-20T17:36:16Z", + "url": "https://www.reddit.com/r/aww/comments/hundvo/cow_loves_the_scritch_machine/", + "read": false, + "rule": 81, + "remote_identifier": "hundvo" + } +}, +{ + "model": "core.post", + "pk": 3090, + "fields": { + "created": "2020-07-20T19:32:35.635Z", + "modified": "2020-07-20T19:32:35.643Z", + "title": "Can I sit next to you ?", + "body": "
      ", + "author": "wheezy098", + "publication_date": "2020-07-20T17:55:10Z", + "url": "https://www.reddit.com/r/aww/comments/hunq5h/can_i_sit_next_to_you/", + "read": false, + "rule": 81, + "remote_identifier": "hunq5h" + } +}, +{ + "model": "core.post", + "pk": 3091, + "fields": { + "created": "2020-07-20T19:32:35.635Z", + "modified": "2020-07-20T19:32:35.645Z", + "title": "IS THAT A CUSTOMER? flop flop flop flop .... \" Can I uhh... help you sir?\"", + "body": "
      ", + "author": "MBMV", + "publication_date": "2020-07-20T12:50:40Z", + "url": "https://www.reddit.com/r/aww/comments/huj7g3/is_that_a_customer_flop_flop_flop_flop_can_i_uhh/", + "read": false, + "rule": 81, + "remote_identifier": "huj7g3" + } +}, +{ + "model": "core.post", + "pk": 3092, + "fields": { + "created": "2020-07-20T19:32:35.635Z", + "modified": "2020-07-20T19:32:35.647Z", + "title": "Good Boy turned Disney Princess", + "body": "
      ", + "author": "Sauwercraud", + "publication_date": "2020-07-20T18:40:05Z", + "url": "https://www.reddit.com/r/aww/comments/huojq0/good_boy_turned_disney_princess/", + "read": false, + "rule": 81, + "remote_identifier": "huojq0" + } +}, +{ + "model": "core.post", + "pk": 3093, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.649Z", + "title": "Kitty loop", + "body": "
      ", + "author": "Dlatrex", + "publication_date": "2020-07-20T12:54:02Z", + "url": "https://www.reddit.com/r/aww/comments/huj8s6/kitty_loop/", + "read": false, + "rule": 81, + "remote_identifier": "huj8s6" + } +}, +{ + "model": "core.post", + "pk": 3094, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.652Z", + "title": "if i fits i sits", + "body": "
      ", + "author": "jasontaken", + "publication_date": "2020-07-20T16:38:32Z", + "url": "https://www.reddit.com/r/aww/comments/humdlf/if_i_fits_i_sits/", + "read": false, + "rule": 81, + "remote_identifier": "humdlf" + } +}, +{ + "model": "core.post", + "pk": 3095, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.654Z", + "title": "Isn\u2019t she Adorable !", + "body": "
      \"Isn\u2019t
      ", + "author": "MunchyMac", + "publication_date": "2020-07-20T16:18:05Z", + "url": "https://www.reddit.com/r/aww/comments/hum133/isnt_she_adorable/", + "read": false, + "rule": 81, + "remote_identifier": "hum133" + } +}, +{ + "model": "core.post", + "pk": 3096, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.655Z", + "title": "Thank you mama (\u2283\uff61\u2022\u0301\u203f\u2022\u0300\uff61)\u2283", + "body": "
      ", + "author": "AnoushkaSingh", + "publication_date": "2020-07-20T13:35:51Z", + "url": "https://www.reddit.com/r/aww/comments/hujpxy/thank_you_mama/", + "read": false, + "rule": 81, + "remote_identifier": "hujpxy" + } +}, +{ + "model": "core.post", + "pk": 3097, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.657Z", + "title": "I WANT TO HUG HIM SO BAD!!!", + "body": "
      ", + "author": "BATMAN_5777", + "publication_date": "2020-07-20T18:25:20Z", + "url": "https://www.reddit.com/r/aww/comments/huo9z4/i_want_to_hug_him_so_bad/", + "read": false, + "rule": 81, + "remote_identifier": "huo9z4" + } +}, +{ + "model": "core.post", + "pk": 3098, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.659Z", + "title": "Before and after being called a good boy", + "body": "
      \"Before
      ", + "author": "vladgrinch", + "publication_date": "2020-07-20T10:48:40Z", + "url": "https://www.reddit.com/r/aww/comments/huhwu9/before_and_after_being_called_a_good_boy/", + "read": false, + "rule": 81, + "remote_identifier": "huhwu9" + } +}, +{ + "model": "core.post", + "pk": 3099, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.662Z", + "title": "My fianc\u00e9 has wanted a dog his whole life. This is his college graduation present. Welcome home Maple!", + "body": "
      \"My
      ", + "author": "AlexisaurusRex", + "publication_date": "2020-07-20T17:57:25Z", + "url": "https://www.reddit.com/r/aww/comments/hunrie/my_fianc\u00e9_has_wanted_a_dog_his_whole_life_this_is/", + "read": false, + "rule": 81, + "remote_identifier": "hunrie" + } +}, +{ + "model": "core.post", + "pk": 3100, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.664Z", + "title": "Cute burro.", + "body": "
      \"Cute
      ", + "author": "Craftmine101", + "publication_date": "2020-07-20T13:45:32Z", + "url": "https://www.reddit.com/r/aww/comments/huju40/cute_burro/", + "read": false, + "rule": 81, + "remote_identifier": "huju40" + } +}, +{ + "model": "core.post", + "pk": 3101, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.666Z", + "title": "I've never seen anyone dance better than that turtle.", + "body": "
      ", + "author": "Ashley1023", + "publication_date": "2020-07-20T18:07:30Z", + "url": "https://www.reddit.com/r/aww/comments/hunya8/ive_never_seen_anyone_dance_better_than_that/", + "read": false, + "rule": 81, + "remote_identifier": "hunya8" + } +}, +{ + "model": "core.post", + "pk": 3102, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.669Z", + "title": "Someone\u2019s going to be quite surprised when he realizes all this new stuff isn\u2019t for him!", + "body": "
      \"Someone\u2019s
      ", + "author": "molly590", + "publication_date": "2020-07-20T15:46:21Z", + "url": "https://www.reddit.com/r/aww/comments/hulikg/someones_going_to_be_quite_surprised_when_he/", + "read": false, + "rule": 81, + "remote_identifier": "hulikg" + } +}, +{ + "model": "core.post", + "pk": 3103, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.671Z", + "title": "my aunt asked me to paint her puppy and I think it turned out so cute!!!", + "body": "
      \"my
      ", + "author": "PineappleLightt", + "publication_date": "2020-07-20T16:39:37Z", + "url": "https://www.reddit.com/r/aww/comments/humea0/my_aunt_asked_me_to_paint_her_puppy_and_i_think/", + "read": false, + "rule": 81, + "remote_identifier": "humea0" + } +}, +{ + "model": "core.post", + "pk": 3104, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.673Z", + "title": "Master Assassin", + "body": "
      \"Master
      ", + "author": "LauWalker", + "publication_date": "2020-07-20T18:47:52Z", + "url": "https://www.reddit.com/r/aww/comments/huop8a/master_assassin/", + "read": false, + "rule": 81, + "remote_identifier": "huop8a" + } +}, +{ + "model": "core.post", + "pk": 3105, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.675Z", + "title": "Every time this tank cleaner cleans out the aquarium, this fish swims over to him looking for pets", + "body": "", + "author": "unnaturalorder", + "publication_date": "2020-07-20T05:29:30Z", + "url": "https://www.reddit.com/r/aww/comments/hue3r0/every_time_this_tank_cleaner_cleans_out_the/", + "read": false, + "rule": 81, + "remote_identifier": "hue3r0" + } +}, +{ + "model": "core.post", + "pk": 3106, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.678Z", + "title": "My girlfriend sent me this while I was at work. And here I was thinking the perfect picture of our dog didn't exist", + "body": "", + "author": "Khuma-zi_Eldrama", + "publication_date": "2020-07-20T19:22:48Z", + "url": "https://www.reddit.com/r/aww/comments/hupdz8/my_girlfriend_sent_me_this_while_i_was_at_work/", + "read": false, + "rule": 81, + "remote_identifier": "hupdz8" + } +}, +{ + "model": "core.post", + "pk": 3107, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.680Z", + "title": "My first ever post, everyone meet my new baby girl Kiora! I\u2019m so in love with her\ud83e\udd7a\ud83d\udcab", + "body": "
      \"My
      ", + "author": "Dumpling2463", + "publication_date": "2020-07-20T05:34:29Z", + "url": "https://www.reddit.com/r/aww/comments/hue6dx/my_first_ever_post_everyone_meet_my_new_baby_girl/", + "read": false, + "rule": 81, + "remote_identifier": "hue6dx" + } +}, +{ + "model": "core.post", + "pk": 3108, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.682Z", + "title": "Dog splashing in water", + "body": "", + "author": "TheRikari", + "publication_date": "2020-07-20T15:44:02Z", + "url": "https://www.reddit.com/r/aww/comments/hulh8k/dog_splashing_in_water/", + "read": false, + "rule": 81, + "remote_identifier": "hulh8k" + } +}, +{ + "model": "core.post", + "pk": 3109, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.685Z", + "title": "They say taking breaks is the key to productivity!", + "body": "
      ", + "author": "Thereaper29", + "publication_date": "2020-07-20T05:43:40Z", + "url": "https://www.reddit.com/r/aww/comments/hueawt/they_say_taking_breaks_is_the_key_to_productivity/", + "read": false, + "rule": 81, + "remote_identifier": "hueawt" + } +}, +{ + "model": "core.post", + "pk": 3110, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.687Z", + "title": "I went away for 3 weeks, and now my cat is in love with my husband", + "body": "
      \"I
      ", + "author": "sillykittyish", + "publication_date": "2020-07-20T03:29:11Z", + "url": "https://www.reddit.com/r/aww/comments/hucd7u/i_went_away_for_3_weeks_and_now_my_cat_is_in_love/", + "read": false, + "rule": 81, + "remote_identifier": "hucd7u" + } +}, +{ + "model": "core.post", + "pk": 3111, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.689Z", + "title": "Can you feel the love", + "body": "
      ", + "author": "kettySewrdPic", + "publication_date": "2020-07-20T09:13:32Z", + "url": "https://www.reddit.com/r/aww/comments/hugx1k/can_you_feel_the_love/", + "read": false, + "rule": 81, + "remote_identifier": "hugx1k" + } +}, +{ + "model": "core.post", + "pk": 3112, + "fields": { + "created": "2020-07-20T19:32:35.835Z", + "modified": "2020-07-21T20:14:50.522Z", + "title": "Linux Experiences/Rants or Education/Certifications thread - July 20, 2020", + "body": "

      Welcome to r/linux rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.

      \n\n

      Let us know what's annoying you, whats making you happy, or something that you want to get out to r/linux but didn't make the cut into a full post of it's own.

      \n\n

      For those looking for certifications please use this megathread to ask about how to get certified whether it's for the business world or for your own satisfaction. Be sure to check out r/linuxadmin for more discussion in the SysAdmin world!

      \n\n

      Please keep questions in r/linuxquestions, r/linux4noobs, or the Wednesday automod thread.

      \n
      ", + "author": "AutoModerator", + "publication_date": "2020-07-20T06:12:00Z", + "url": "https://www.reddit.com/r/linux/comments/hueoo0/linux_experiencesrants_or_educationcertifications/", + "read": false, + "rule": 80, + "remote_identifier": "hueoo0" + } +}, +{ + "model": "core.post", + "pk": 3113, + "fields": { + "created": "2020-07-20T19:32:35.836Z", + "modified": "2020-07-21T20:19:49.339Z", + "title": "Unix Family Tree", + "body": "
      \"Unix
      ", + "author": "bauripalash", + "publication_date": "2020-07-20T10:32:15Z", + "url": "https://www.reddit.com/r/linux/comments/huhqrh/unix_family_tree/", + "read": true, + "rule": 80, + "remote_identifier": "huhqrh" + } +}, +{ + "model": "core.post", + "pk": 3114, + "fields": { + "created": "2020-07-20T19:32:35.836Z", + "modified": "2020-07-21T20:14:50.554Z", + "title": "NVIDIA open sourced part of NVAPI SDK to aid 'Windows emulation environments'", + "body": "", + "author": "ignapk", + "publication_date": "2020-07-20T13:17:19Z", + "url": "https://www.reddit.com/r/linux/comments/huji8c/nvidia_open_sourced_part_of_nvapi_sdk_to_aid/", + "read": false, + "rule": 80, + "remote_identifier": "huji8c" + } +}, +{ + "model": "core.post", + "pk": 3115, + "fields": { + "created": "2020-07-20T19:32:35.836Z", + "modified": "2020-07-21T20:14:50.551Z", + "title": "Jellyfin 10.6 released", + "body": "", + "author": "resoluti0n_", + "publication_date": "2020-07-20T16:40:05Z", + "url": "https://www.reddit.com/r/linux/comments/humekr/jellyfin_106_released/", + "read": false, + "rule": 80, + "remote_identifier": "humekr" + } +}, +{ + "model": "core.post", + "pk": 3116, + "fields": { + "created": "2020-07-20T19:32:35.836Z", + "modified": "2020-07-21T20:14:50.583Z", + "title": "[German] Article in major german newspaper about trying Linux and WSL. Literal: \"Why it's beneficial to try Linux now\"", + "body": "", + "author": "noname7890", + "publication_date": "2020-07-19T15:19:27Z", + "url": "https://www.reddit.com/r/linux/comments/hu0d5v/german_article_in_major_german_newspaper_about/", + "read": false, + "rule": 80, + "remote_identifier": "hu0d5v" + } +}, +{ + "model": "core.post", + "pk": 3117, + "fields": { + "created": "2020-07-20T19:32:35.837Z", + "modified": "2020-07-21T20:14:50.574Z", + "title": "Brian Kernighan: UNIX, C, AWK, AMPL, and Go Programming | AI Podcast #109 with Lex Fridman", + "body": "", + "author": "tinyatom", + "publication_date": "2020-07-20T08:48:35Z", + "url": "https://www.reddit.com/r/linux/comments/hugn0w/brian_kernighan_unix_c_awk_ampl_and_go/", + "read": false, + "rule": 80, + "remote_identifier": "hugn0w" + } +}, +{ + "model": "core.post", + "pk": 3118, + "fields": { + "created": "2020-07-20T19:32:35.837Z", + "modified": "2020-07-21T20:14:50.578Z", + "title": "Explaining Computers Host Christopher Barnatt Has Switched To Linux", + "body": "", + "author": "sysrpl", + "publication_date": "2020-07-20T13:00:02Z", + "url": "https://www.reddit.com/r/linux/comments/hujb12/explaining_computers_host_christopher_barnatt_has/", + "read": false, + "rule": 80, + "remote_identifier": "hujb12" + } +}, +{ + "model": "core.post", + "pk": 3119, + "fields": { + "created": "2020-07-20T19:32:35.837Z", + "modified": "2020-07-21T20:14:50.529Z", + "title": "Ireland donates contact tracing app to the Linux foundation.", + "body": "", + "author": "mathiasryan", + "publication_date": "2020-07-20T21:31:43Z", + "url": "https://www.reddit.com/r/linux/comments/hury4e/ireland_donates_contact_tracing_app_to_the_linux/", + "read": false, + "rule": 80, + "remote_identifier": "hury4e" + } +}, +{ + "model": "core.post", + "pk": 3120, + "fields": { + "created": "2020-07-20T19:32:35.842Z", + "modified": "2020-07-21T20:14:50.588Z", + "title": "I implemented a simple terminal-based password manager", + "body": "

      I created a simple, secure, and free password manager written in C: SaltPass. I haven't contributed open source code before, but I think this might be useful to a few people. Especially as an alternative to paid solutions such as LastPass and the likes. Any suggestions/edits/code improvements would be greatly appreciated!

      \n
      ", + "author": "zaid-gg", + "publication_date": "2020-07-20T07:43:03Z", + "url": "https://www.reddit.com/r/linux/comments/hufula/i_implemented_a_simple_terminalbased_password/", + "read": false, + "rule": 80, + "remote_identifier": "hufula" + } +}, +{ + "model": "core.post", + "pk": 3121, + "fields": { + "created": "2020-07-20T19:32:35.843Z", + "modified": "2020-07-21T20:14:50.593Z", + "title": "Performance analysis of multi services on container Docker, LXC, and LXD - Bulletin of Electrical Engineering and Informatics, Adinda Riztia Putri, Rendy Munadi, Ridha Muldina Negara Adaptive Network\u2026", + "body": "", + "author": "bmullan", + "publication_date": "2020-07-20T11:35:59Z", + "url": "https://www.reddit.com/r/linux/comments/huieio/performance_analysis_of_multi_services_on/", + "read": false, + "rule": 80, + "remote_identifier": "huieio" + } +}, +{ + "model": "core.post", + "pk": 3122, + "fields": { + "created": "2020-07-20T19:32:35.844Z", + "modified": "2020-07-21T20:14:50.602Z", + "title": "Create an Internal PKI using OpenSSL and NitroKey HSM", + "body": "", + "author": "PixelPaulaus", + "publication_date": "2020-07-20T06:18:41Z", + "url": "https://www.reddit.com/r/linux/comments/huerpn/create_an_internal_pki_using_openssl_and_nitrokey/", + "read": false, + "rule": 80, + "remote_identifier": "huerpn" + } +}, +{ + "model": "core.post", + "pk": 3123, + "fields": { + "created": "2020-07-20T19:32:35.844Z", + "modified": "2020-07-20T19:32:35.883Z", + "title": "vopono - run applications via VPNs with temporary network namespaces", + "body": "", + "author": "nivenkos", + "publication_date": "2020-07-19T20:02:57Z", + "url": "https://www.reddit.com/r/linux/comments/hu4vge/vopono_run_applications_via_vpns_with_temporary/", + "read": false, + "rule": 80, + "remote_identifier": "hu4vge" + } +}, +{ + "model": "core.post", + "pk": 3124, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.886Z", + "title": "Double (triple, quadruple...) internet speed with openvpn tap channel bonding to a linux VPS", + "body": "

      I have been working a couple of days on my latest video about channel bonding - the video is heavily inspired be this article on Serverfault. In essence, I have been searching for a while on how to bond multiple VPN channels together in order to increase internet speed - there does not seem to be a lot of information around - mainly articles on forums and reddit state that it should be possible but a detailed guide is hard to find. I am using two Ubuntu machines in order to build the connection - one local and one VPS. The bash scripts I use in my video in order to achieve tap channel bonding are available on my github repository. I am currently working on a second video in order to walk through and explain the scripts in depth. Enjoy!

      \n\n

      (EDIT) - the question has come up in the discussions below if this is really packet load balancing or rather balancing links only - please see my comment further down - I can confirm that this DOES packet balancing so it does work as described.

      \n
      ", + "author": "onemarcfifty", + "publication_date": "2020-07-19T20:41:40Z", + "url": "https://www.reddit.com/r/linux/comments/hu5l4f/double_triple_quadruple_internet_speed_with/", + "read": false, + "rule": 80, + "remote_identifier": "hu5l4f" + } +}, +{ + "model": "core.post", + "pk": 3125, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.888Z", + "title": "OpenRGB - Open source RGB lighting control that doesn't depend on manufacturer software, supports Linux", + "body": "", + "author": "pr0_c0d3", + "publication_date": "2020-07-18T16:52:48Z", + "url": "https://www.reddit.com/r/linux/comments/hthuli/openrgb_open_source_rgb_lighting_control_that/", + "read": false, + "rule": 80, + "remote_identifier": "hthuli" + } +}, +{ + "model": "core.post", + "pk": 3126, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.890Z", + "title": "Make this any sense? Automatic CPU Speed & Power Optimizer", + "body": "", + "author": "spite77", + "publication_date": "2020-07-20T11:53:35Z", + "url": "https://www.reddit.com/r/linux/comments/huikxz/make_this_any_sense_automatic_cpu_speed_power/", + "read": false, + "rule": 80, + "remote_identifier": "huikxz" + } +}, +{ + "model": "core.post", + "pk": 3127, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.891Z", + "title": "Let\u2019s not be pedantic about \u201cOpen Source\u201d", + "body": "", + "author": "speckz", + "publication_date": "2020-07-20T16:46:43Z", + "url": "https://www.reddit.com/r/linux/comments/humirw/lets_not_be_pedantic_about_open_source/", + "read": false, + "rule": 80, + "remote_identifier": "humirw" + } +}, +{ + "model": "core.post", + "pk": 3128, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.893Z", + "title": "Experiences with running Linux Lite", + "body": "", + "author": "daemonpenguin", + "publication_date": "2020-07-20T02:43:49Z", + "url": "https://www.reddit.com/r/linux/comments/hubonw/experiences_with_running_linux_lite/", + "read": false, + "rule": 80, + "remote_identifier": "hubonw" + } +}, +{ + "model": "core.post", + "pk": 3129, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.895Z", + "title": "Tried gnome on arch, surprised how lean it is (used flameshot so it used about 72mb more) closing at 600 megs) on fedora and pop i had gnome eating up 1.3gigs at boot up.", + "body": "
      \"Tried
      ", + "author": "V1n0dKr1shna", + "publication_date": "2020-07-18T13:54:55Z", + "url": "https://www.reddit.com/r/linux/comments/htfeph/tried_gnome_on_arch_surprised_how_lean_it_is_used/", + "read": false, + "rule": 80, + "remote_identifier": "htfeph" + } +}, +{ + "model": "core.post", + "pk": 3130, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.897Z", + "title": "The Free Software Foundation is holding a Fundraiser, help them reach 200 members", + "body": "", + "author": "Neet-Feet", + "publication_date": "2020-07-18T17:55:30Z", + "url": "https://www.reddit.com/r/linux/comments/htiuyi/the_free_software_foundation_is_holding_a/", + "read": false, + "rule": 80, + "remote_identifier": "htiuyi" + } +}, +{ + "model": "core.post", + "pk": 3131, + "fields": { + "created": "2020-07-20T19:32:35.853Z", + "modified": "2020-07-20T19:32:35.899Z", + "title": "Why is the mindset around Arch so negative?", + "body": "

      I love the Linux community as a whole. You can find some of the most creative and imaginative people within most Linux communities. On a whole, Linux users are some of the most helpful and informative people you can encounter. Truly the type to think outside the box and learn new things. It can be very inspirational.

      \n\n

      If I jumped onto Ubuntu, Fedora, or openSUSE's community I can have a free flowing conversation about Linux, their distribution, and getting help or giving help is so free-flowing and easy. The communities are eager to welcome new people and appreciate folks who contribute.

      \n\n

      Then you have Arch. I love the OS but dislike the mindset. Asking for help is meat with resistance, giving help can also be punishable, and god forbid you try to have a discussion. But it's not just their core community either. For example, I just discovered Endeavour OS which is built around Arch and after 11 post I'm told to come back in 8 hours. Their subReddit here on Reddit, you have to ask to even make 1 post. There of course is also Manjaro Linux and they too have this gatekeeper mindset, the same can be said for ArcoLinux.

      \n\n

      What is it about Arch that makes everyone want to be either a control freak or a gatekeeper?

      \n\n

      I do not see this within the Ubuntu or Fedora or openSUSE communities. As I said, their mindset seems eager and willing to unite and work as a community. Am I the only how has noticed this?

      \n
      ", + "author": "Linux-Is-Best", + "publication_date": "2020-07-18T23:28:12Z", + "url": "https://www.reddit.com/r/linux/comments/htojwk/why_is_the_mindset_around_arch_so_negative/", + "read": false, + "rule": 80, + "remote_identifier": "htojwk" + } +}, +{ + "model": "core.post", + "pk": 3132, + "fields": { + "created": "2020-07-20T19:32:35.853Z", + "modified": "2020-07-20T19:32:35.901Z", + "title": "Using the nstat network statistics command in Linux", + "body": "", + "author": "cronos426", + "publication_date": "2020-07-19T17:55:55Z", + "url": "https://www.reddit.com/r/linux/comments/hu2q6v/using_the_nstat_network_statistics_command_in/", + "read": false, + "rule": 80, + "remote_identifier": "hu2q6v" + } +}, +{ + "model": "core.post", + "pk": 3133, + "fields": { + "created": "2020-07-20T19:32:35.853Z", + "modified": "2020-07-20T19:32:35.903Z", + "title": "Contributing via GitLab Merge Requests", + "body": "", + "author": "ChristophCullmann", + "publication_date": "2020-07-18T20:01:26Z", + "url": "https://www.reddit.com/r/linux/comments/htl05p/contributing_via_gitlab_merge_requests/", + "read": false, + "rule": 80, + "remote_identifier": "htl05p" + } +}, +{ + "model": "core.post", + "pk": 3134, + "fields": { + "created": "2020-07-20T19:32:35.853Z", + "modified": "2020-07-20T19:32:35.905Z", + "title": "OpenMandriva: combines WINE64 and 32 into one package capable of running both binaries, i686 architecture was considered as deprecated. Work is underway on a new Rolling release", + "body": "", + "author": "DamonsLinux", + "publication_date": "2020-07-18T15:02:35Z", + "url": "https://www.reddit.com/r/linux/comments/htg9dj/openmandriva_combines_wine64_and_32_into_one/", + "read": false, + "rule": 80, + "remote_identifier": "htg9dj" + } +}, +{ + "model": "core.post", + "pk": 3135, + "fields": { + "created": "2020-07-20T19:32:35.853Z", + "modified": "2020-07-20T19:32:35.906Z", + "title": "OpenRCT2 Player Survey 2020 - Previous survey shows almost 25% players are linux, please help represent linux in the most recent survey", + "body": "", + "author": "christophski", + "publication_date": "2020-07-18T11:39:06Z", + "url": "https://www.reddit.com/r/linux/comments/htdzuh/openrct2_player_survey_2020_previous_survey_shows/", + "read": false, + "rule": 80, + "remote_identifier": "htdzuh" + } +}, +{ + "model": "core.post", + "pk": 3136, + "fields": { + "created": "2020-07-20T19:32:35.853Z", + "modified": "2020-07-20T19:32:35.908Z", + "title": "This week in KDE: Get New Stuff fixes and more", + "body": "", + "author": "kyentei", + "publication_date": "2020-07-18T10:03:46Z", + "url": "https://www.reddit.com/r/linux/comments/htd1an/this_week_in_kde_get_new_stuff_fixes_and_more/", + "read": false, + "rule": 80, + "remote_identifier": "htd1an" + } +}, +{ + "model": "core.post", + "pk": 3137, + "fields": { + "created": "2020-07-20T19:32:35.857Z", + "modified": "2020-07-20T19:32:35.910Z", + "title": "Blender Runs on Linux Pinephone", + "body": "

      I managed to get the desktop version of Blender on the Pinephone, and it works really well except for a few bugs.

      \n\n

      See my post on r/blender:

      \n\n

      https://www.reddit.com/r/blender/comments/hsxv27/i_installed_blender_on_a_phone/

      \n\n

      and r/PINE64official:

      \n\n

      https://www.reddit.com/r/PINE64official/comments/hsxc33/blender_on_pine_phone_almost_usable/

      \n\n

      I've tried other desktop programs like Xournal and PPSSPP, their UIs also work well, I'd be able to do even more if OpenGL 3 was working.

      \n
      ", + "author": "InfiniteHawk", + "publication_date": "2020-07-17T22:35:14Z", + "url": "https://www.reddit.com/r/linux/comments/ht3d4k/blender_runs_on_linux_pinephone/", + "read": false, + "rule": 80, + "remote_identifier": "ht3d4k" + } +}, +{ + "model": "core.post", + "pk": 3138, + "fields": { + "created": "2020-07-21T20:14:50.415Z", + "modified": "2020-07-21T20:18:21.616Z", + "title": "Hrmmm They Need to Fix Throttle Animations in the Sabre", + "body": "
      ", + "author": "TheBootRanger", + "publication_date": "2020-07-21T13:26:01Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv5omc/hrmmm_they_need_to_fix_throttle_animations_in_the/", + "read": true, + "rule": 82, + "remote_identifier": "hv5omc" + } +}, +{ + "model": "core.post", + "pk": 3139, + "fields": { + "created": "2020-07-21T20:14:50.415Z", + "modified": "2020-07-21T20:18:49.999Z", + "title": "My first 3.10 landing could have gone better...", + "body": "
      ", + "author": "KnLfey", + "publication_date": "2020-07-21T16:04:50Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv7w85/my_first_310_landing_could_have_gone_better/", + "read": true, + "rule": 82, + "remote_identifier": "hv7w85" + } +}, +{ + "model": "core.post", + "pk": 3140, + "fields": { + "created": "2020-07-21T20:14:50.415Z", + "modified": "2020-07-21T20:14:50.439Z", + "title": "How about the Christmas in 3 more years?", + "body": "
      \"How
      ", + "author": "SpleanEater", + "publication_date": "2020-07-21T17:49:22Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv9qy8/how_about_the_christmas_in_3_more_years/", + "read": false, + "rule": 82, + "remote_identifier": "hv9qy8" + } +}, +{ + "model": "core.post", + "pk": 3141, + "fields": { + "created": "2020-07-21T20:14:50.415Z", + "modified": "2020-07-21T20:18:33.532Z", + "title": "Long time Elite Dangerous player. New to star citizen i think im doing great", + "body": "", + "author": "Filblo5", + "publication_date": "2020-07-21T15:33:49Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv7elb/long_time_elite_dangerous_player_new_to_star/", + "read": true, + "rule": 82, + "remote_identifier": "hv7elb" + } +}, +{ + "model": "core.post", + "pk": 3142, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:14:50.443Z", + "title": "And we stand by it.", + "body": "
      \"And
      ", + "author": "CyberTill", + "publication_date": "2020-07-21T18:57:48Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvb3wm/and_we_stand_by_it/", + "read": false, + "rule": 82, + "remote_identifier": "hvb3wm" + } +}, +{ + "model": "core.post", + "pk": 3143, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:14:50.446Z", + "title": "Nomad", + "body": "
      \"Nomad\"
      ", + "author": "ibracitizen", + "publication_date": "2020-07-21T19:52:24Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvc5h3/nomad/", + "read": false, + "rule": 82, + "remote_identifier": "hvc5h3" + } +}, +{ + "model": "core.post", + "pk": 3144, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:14:50.449Z", + "title": "Probably the best screen cap i've ever caught on a whim. 3.5 Arc Corp release. Also a confession: I never pledged. Got a ship with my GPU. I intend to pay my dues.", + "body": "
      \"Probably
      ", + "author": "ScionoicS", + "publication_date": "2020-07-21T20:23:01Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvcqzf/probably_the_best_screen_cap_ive_ever_caught_on_a/", + "read": false, + "rule": 82, + "remote_identifier": "hvcqzf" + } +}, +{ + "model": "core.post", + "pk": 3145, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:14:50.451Z", + "title": "Play to escape the depressing job hunt where I need 10 years experience for a entry level job to find this, only been playing for 1 and a half years :(", + "body": "
      \"Play
      ", + "author": "Albert-III-", + "publication_date": "2020-07-21T12:23:45Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv4z08/play_to_escape_the_depressing_job_hunt_where_i/", + "read": false, + "rule": 82, + "remote_identifier": "hv4z08" + } +}, +{ + "model": "core.post", + "pk": 3146, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:19:00.691Z", + "title": "The void beckons.", + "body": "
      ", + "author": "HisNameWasHis", + "publication_date": "2020-07-21T14:40:51Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv6nij/the_void_beckons/", + "read": true, + "rule": 82, + "remote_identifier": "hv6nij" + } +}, +{ + "model": "core.post", + "pk": 3147, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:19:05.881Z", + "title": "I made a SC-like Photobash with Soldiers", + "body": "
      \"I
      ", + "author": "IsaacPolar", + "publication_date": "2020-07-21T17:13:39Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv92ri/i_made_a_sclike_photobash_with_soldiers/", + "read": true, + "rule": 82, + "remote_identifier": "hv92ri" + } +}, +{ + "model": "core.post", + "pk": 3148, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:19:41.227Z", + "title": "Ocean Shader Improvements", + "body": "
      \"Ocean
      ", + "author": "shoeii", + "publication_date": "2020-07-21T18:41:51Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvasds/ocean_shader_improvements/", + "read": true, + "rule": 82, + "remote_identifier": "hvasds" + } +}, +{ + "model": "core.post", + "pk": 3149, + "fields": { + "created": "2020-07-21T20:14:50.420Z", + "modified": "2020-07-21T20:14:50.459Z", + "title": "As much shit as Star Citizen (rightfully) gets it still does one thing better than any other 'game' I've played", + "body": "

      It invokes a real sense of scale, on multiple levels.

      \n\n

      One could argue that's one of the most important feelings you'd want to capture in any game set in space, but of course it's mostly meaningless if there aren't enough gameplay loops and systems in place to work in tandem with and make the space that's been created interesting, and that's where SC is currently a failure.

      \n\n

      Even so, I think being able to create that sense of smallness isn't insignificant.

      \n\n

      You as a pilot are dwarfed by your ship which is itself dwarfed by a larger ship which is itself dwarfed by another, even more massive one which is dwarfed by the space station or hub you're at which is dwarfed by a crater on a moon which is dwarfed by the moon itself which is dwarfed by the planet it orbits which is dwarfed by the sheer vastness of space in between all of those things and that they are, despite the distance, still connected.

      \n\n

      Getting lost in Lorville (even if it is mostly linear) and knowing it's only a small part of the playable space is a really neat feeling - looking out from the windows of the train up into the sky and knowing you can go there and beyond really makes you feel like there is a whole world (and more) waiting to be explored.

      \n\n

      I think this is a direct result of having legs and not being locked into the cockpit of your ship - I've played more Elite: Dangerous than Star Citizen and it accomplishes a similar sense of scale but, at least not as far as I've felt, never to the same degree - because you're locked in your ship you never really get this same sense of being small or insignificant even though you are dwarfed in similar ways by planets/asteroids/other ships - will be interesting to see how their implementation of 'space legs' in the upcoming expansion changes this.

      \n\n

      My favourite thing to do in Star Citizen (because there isn't a whole lot) is to just find some pocket of space far away from anything else and just walk around my ship, feeling truly alone and insignificant, gazing out at the void that stretches infinitely all around - something about that is super comfy.

      \n\n

      I can't think of many other game that accomplish a similar level of scale though I'm sure they exist.

      \n\n

      I've been playing an indie game called Empyrion - Galactic Survival and it actually is sort of similar to SC in this regard but it's nowhere near as polished or smooth - transitions from atmosphere to space are not truly seamless and planets themselves are kind of stitched together, but it still manages to invoke that same kind of awe at the scale of things when you dock a small vessel to a capital vessel, for example - definitely worth checking out if you like sci-fi/space games, which you must if you're here, but just be prepared for the jank.

      \n
      ", + "author": "thegreatself", + "publication_date": "2020-07-21T20:30:15Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvcw38/as_much_shit_as_star_citizen_rightfully_gets_it/", + "read": false, + "rule": 82, + "remote_identifier": "hvcw38" + } +}, +{ + "model": "core.post", + "pk": 3150, + "fields": { + "created": "2020-07-21T20:14:50.420Z", + "modified": "2020-07-21T20:14:50.462Z", + "title": "You waiting for patch 3.10 to go live while watching tons of videos about the new flight model features. Be patient, 3.11 and 3.12 will be even better.", + "body": "
      \"You
      ", + "author": "jsabater76", + "publication_date": "2020-07-21T09:39:27Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv372v/you_waiting_for_patch_310_to_go_live_while/", + "read": false, + "rule": 82, + "remote_identifier": "hv372v" + } +}, +{ + "model": "core.post", + "pk": 3151, + "fields": { + "created": "2020-07-21T20:14:50.420Z", + "modified": "2020-07-21T20:14:50.466Z", + "title": "CIG, can we please fix these \"black hole\" doors(when they are closed) on ships please.", + "body": "
      \"CIG,
      ", + "author": "AbnormallyBendPenis", + "publication_date": "2020-07-21T13:40:14Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv5uzj/cig_can_we_please_fix_these_black_hole_doorswhen/", + "read": false, + "rule": 82, + "remote_identifier": "hv5uzj" + } +}, +{ + "model": "core.post", + "pk": 3152, + "fields": { + "created": "2020-07-21T20:14:50.420Z", + "modified": "2020-07-21T20:14:50.468Z", + "title": "Anvil Super Hornet over Cellin", + "body": "
      \"Anvil
      ", + "author": "SaraCaterina", + "publication_date": "2020-07-21T20:33:58Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvcyq6/anvil_super_hornet_over_cellin/", + "read": false, + "rule": 82, + "remote_identifier": "hvcyq6" + } +}, +{ + "model": "core.post", + "pk": 3153, + "fields": { + "created": "2020-07-21T20:14:50.420Z", + "modified": "2020-07-21T20:14:50.471Z", + "title": "3.10 Combat Changes", + "body": "", + "author": "STLYoungblood", + "publication_date": "2020-07-21T16:37:44Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv8fr7/310_combat_changes/", + "read": false, + "rule": 82, + "remote_identifier": "hv8fr7" + } +}, +{ + "model": "core.post", + "pk": 3154, + "fields": { + "created": "2020-07-21T20:14:50.420Z", + "modified": "2020-07-21T20:14:50.472Z", + "title": "Hey CIG how about that S42 Vi.... Oh...", + "body": "
      \"Hey
      ", + "author": "SiEDeN", + "publication_date": "2020-07-21T21:37:16Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hve6am/hey_cig_how_about_that_s42_vi_oh/", + "read": false, + "rule": 82, + "remote_identifier": "hve6am" + } +}, +{ + "model": "core.post", + "pk": 3155, + "fields": { + "created": "2020-07-21T20:14:50.422Z", + "modified": "2020-07-21T20:14:50.475Z", + "title": "3.10 M PTU Eclipse improvements", + "body": "

      If this goes live, CIG had addressed 2 of my Eclipse critics.

      \n\n

      Not because of my videos of course, CIG doesn't know I exist.

      \n\n

       

      \n\n

      a. Eclipse has armor stealth in 3.10, see my table:\nhttps://docs.google.com/spreadsheets/d/1OJXg7MQsG_IVTPsmlmZYaxEPK4n4iqnhQx4oigIlJHg/edit#gid=343807746

      \n\n

       

      \n\n

      b. Eclipse can fire her size 9 torpedoes way quicker now, see my video with a side by side comparison of the max firing speed in 3.9 and 3.10:\nhttps://youtu.be/GFTF1Qt7T3o?t=207

      \n
      ", + "author": "Camural", + "publication_date": "2020-07-21T18:15:50Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hva9lc/310_m_ptu_eclipse_improvements/", + "read": false, + "rule": 82, + "remote_identifier": "hva9lc" + } +}, +{ + "model": "core.post", + "pk": 3156, + "fields": { + "created": "2020-07-21T20:14:50.422Z", + "modified": "2020-07-21T20:14:50.477Z", + "title": "Hark! The Drake Herald Sings", + "body": "
      \"Hark!
      ", + "author": "CyrexStorm", + "publication_date": "2020-07-21T16:19:31Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv84kk/hark_the_drake_herald_sings/", + "read": false, + "rule": 82, + "remote_identifier": "hv84kk" + } +}, +{ + "model": "core.post", + "pk": 3157, + "fields": { + "created": "2020-07-21T20:14:50.422Z", + "modified": "2020-07-21T20:14:50.479Z", + "title": "The new flight stick in the Prowler", + "body": "
      \"The
      ", + "author": "Potato_Nades", + "publication_date": "2020-07-21T16:22:22Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv86c2/the_new_flight_stick_in_the_prowler/", + "read": false, + "rule": 82, + "remote_identifier": "hv86c2" + } +}, +{ + "model": "core.post", + "pk": 3158, + "fields": { + "created": "2020-07-21T20:14:50.422Z", + "modified": "2020-07-21T20:14:50.481Z", + "title": "Norwegian VAT charged from August 1st", + "body": "
      \"Norwegian
      ", + "author": "norgeek", + "publication_date": "2020-07-21T10:30:57Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv3r3l/norwegian_vat_charged_from_august_1st/", + "read": false, + "rule": 82, + "remote_identifier": "hv3r3l" + } +}, +{ + "model": "core.post", + "pk": 3159, + "fields": { + "created": "2020-07-21T20:14:50.423Z", + "modified": "2020-07-21T20:14:50.484Z", + "title": "With Pyro (currently WIP), Nyx (partially done), Odin (S42), currently on the way, what is everyone\u2019s thoughts on Terra possibly being next on the list of star systems to be added into the PU within \u2026", + "body": "
      \"With
      ", + "author": "realCLTotaku", + "publication_date": "2020-07-21T13:27:09Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv5p41/with_pyro_currently_wip_nyx_partially_done_odin/", + "read": false, + "rule": 82, + "remote_identifier": "hv5p41" + } +}, +{ + "model": "core.post", + "pk": 3160, + "fields": { + "created": "2020-07-21T20:14:50.423Z", + "modified": "2020-07-21T20:14:50.486Z", + "title": "Testing out the new electron rifle", + "body": "
      ", + "author": "joshbaker2112", + "publication_date": "2020-07-21T02:56:19Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huxr6d/testing_out_the_new_electron_rifle/", + "read": false, + "rule": 82, + "remote_identifier": "huxr6d" + } +}, +{ + "model": "core.post", + "pk": 3161, + "fields": { + "created": "2020-07-21T20:14:50.423Z", + "modified": "2020-07-21T20:14:50.487Z", + "title": "Imperial Geographic's Lovecraftian magazine special is here. \ud83d\udc19 Find the link in the comments!", + "body": "
      \"Imperial
      ", + "author": "Good_Punk2", + "publication_date": "2020-07-21T18:21:38Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvadrh/imperial_geographics_lovecraftian_magazine/", + "read": false, + "rule": 82, + "remote_identifier": "hvadrh" + } +}, +{ + "model": "core.post", + "pk": 3162, + "fields": { + "created": "2020-07-21T20:14:50.497Z", + "modified": "2020-07-21T20:14:50.525Z", + "title": "Linux Distributions Timeline", + "body": "
      \"Linux
      ", + "author": "bauripalash", + "publication_date": "2020-07-21T06:07:59Z", + "url": "https://www.reddit.com/r/linux/comments/hv0ktn/linux_distributions_timeline/", + "read": false, + "rule": 80, + "remote_identifier": "hv0ktn" + } +}, +{ + "model": "core.post", + "pk": 3163, + "fields": { + "created": "2020-07-21T20:14:50.497Z", + "modified": "2020-07-21T20:14:50.527Z", + "title": "Fedora: Proposal to replace default wined3d backend with DXVK", + "body": "", + "author": "friskfrugt", + "publication_date": "2020-07-21T19:42:49Z", + "url": "https://www.reddit.com/r/linux/comments/hvbyyr/fedora_proposal_to_replace_default_wined3d/", + "read": false, + "rule": 80, + "remote_identifier": "hvbyyr" + } +}, +{ + "model": "core.post", + "pk": 3164, + "fields": { + "created": "2020-07-21T20:14:50.497Z", + "modified": "2020-07-21T20:14:50.531Z", + "title": "Update on marketing and communication plans for the LibreOffice 7.x series", + "body": "", + "author": "TheQuantumZero", + "publication_date": "2020-07-21T09:59:23Z", + "url": "https://www.reddit.com/r/linux/comments/hv3erm/update_on_marketing_and_communication_plans_for/", + "read": false, + "rule": 80, + "remote_identifier": "hv3erm" + } +}, +{ + "model": "core.post", + "pk": 3165, + "fields": { + "created": "2020-07-21T20:14:50.497Z", + "modified": "2020-07-21T20:14:50.533Z", + "title": "FOSS job opening: LibreOffice Development Mentor at The Document Foundation", + "body": "", + "author": "themikeosguy", + "publication_date": "2020-07-21T14:26:36Z", + "url": "https://www.reddit.com/r/linux/comments/hv6gfw/foss_job_opening_libreoffice_development_mentor/", + "read": false, + "rule": 80, + "remote_identifier": "hv6gfw" + } +}, +{ + "model": "core.post", + "pk": 3166, + "fields": { + "created": "2020-07-21T20:14:50.503Z", + "modified": "2020-07-21T20:14:50.536Z", + "title": "gomd - quickly display formatted markdown files with code highlight in your browser", + "body": "

      Hi all!

      \n\n

      I wanted to share a project I've been working on recently. I think it reached a stage where it's pretty usable and should work out of the box. gomd sets up a HTTP server and serves a directory in your browser so you can quickly view your markdown files. It comes with some neat features like:

      \n\n
        \n
      • Monitoring files - it will monitor files for changes and reload them whenever needed
      • \n
      • Hot reloading - whenever the file you are currently viewing changes, the tab in your browser will reload automatically.
      • \n
      • Code Highlight - All blocks of code in most common languages will be color highlighted.
      • \n
      • Themes - choose from multiple themes like: solarized, monokai, github, dracula...
      • \n
      \n\n

      Link: gomd

      \n\n

      For now its only available from AUR or built from source.

      \n\n

      \n\n

      Any tips or feedback will be greatly appreciated :)

      \n
      ", + "author": "wwojtekk", + "publication_date": "2020-07-21T20:07:31Z", + "url": "https://www.reddit.com/r/linux/comments/hvcg44/gomd_quickly_display_formatted_markdown_files/", + "read": false, + "rule": 80, + "remote_identifier": "hvcg44" + } +}, +{ + "model": "core.post", + "pk": 3167, + "fields": { + "created": "2020-07-21T20:14:50.503Z", + "modified": "2020-07-21T20:14:50.543Z", + "title": "They're not otherwise wrong, but it didn't become a real Internet standard until 2017.", + "body": "
      \"They're
      ", + "author": "foodown", + "publication_date": "2020-07-21T21:39:09Z", + "url": "https://www.reddit.com/r/linux/comments/hve7l5/theyre_not_otherwise_wrong_but_it_didnt_become_a/", + "read": false, + "rule": 80, + "remote_identifier": "hve7l5" + } +}, +{ + "model": "core.post", + "pk": 3168, + "fields": { + "created": "2020-07-21T20:14:50.503Z", + "modified": "2020-07-21T20:14:50.545Z", + "title": "Drawing - an alternative to Paint for Linux (gtk3, support HiDPI)", + "body": "", + "author": "dontdieych", + "publication_date": "2020-07-21T02:37:22Z", + "url": "https://www.reddit.com/r/linux/comments/huxgsg/drawing_an_alternative_to_paint_for_linux_gtk3/", + "read": false, + "rule": 80, + "remote_identifier": "huxgsg" + } +}, +{ + "model": "core.post", + "pk": 3169, + "fields": { + "created": "2020-07-21T20:14:50.509Z", + "modified": "2020-07-21T20:14:50.547Z", + "title": "Observations on a Linux issue with 3.5mm earphones with a mic", + "body": "

      Alright hello. I have come from r/SolusProject and I made a post there to do with headphone issues. I suggest you read through the post and comments to get a better understanding before reading this https://www.reddit.com/r/SolusProject/comments/hsql4d/frustrating_headphone_issues/. I had posted to do with it again, but it got taken down for duplication (when it wasn't duplication). This post is more of my observations from experimenting and such. There are distros I haven't tried but I tried a wide range of distros like manjaro, ubuntu based ones and all solus flavors, and I was looking more for how well they worked out of the box, rather than with fiddling around with pulse, hdajack etc which I know will work eventually. If you stumbled across this from searching about the same issue I have (or similar) or are confused to what this is about, I suggest you look at my previous post also.

      \n\n

      So anyways, I've tried the past few days mounting isos to usb drives and trying live os and installing various distros to see about the headphone issue. And my conclusion is that this issue affects the linux kernel in some way across the board. I don't really understand why completely but I have some kind of idea.

      \n\n

      From installing fresh distros, I noticed that the earphones (they are 3.5mm earphones + mic) get recognised as a microphone and not as a speaker system of some kind. Every single time I had a look at the sound settings and in pulse, they came up as plugged microphone, with the internal speakers being the only output device every single time. It's really odd seeing as how ubuntu 14.04 and xubuntu etc from years past worked flawlessly with the earphones, even manjaro a while ago on my older craptop worked fine. I don't really understand why it doesn't work on my device now.

      \n\n

      I'll leave my specs at the bottom of this post but what I think is is there's something the manufacturer did, or something like the cpu causes issue with linux. The manufacturer of my laptop is Lenovo, and the cpu/igpu is from AMD. A warning sign is that when installing a linux distro, it doesn't bring up the dual boot menu at startup like it should. Instead it completely hides the fact it exists until I use something like easyuefi to add an option for that distro, how it works is you specify the boot partition, whether it's linux or windows and the loader conf file for the distro. All of this hassle everytime doesn't appear on my craptop, because the dual boot menu appears flawlessly without issue. May be because it uses an Intel cpu/igpu unlike my newer laptop but it's hard to say.

      \n\n

      Also, it seems like the devices that appear in a given distro when looking at alsa, is hd generic devices but by reloading alsa or any command that shows the full name of the device, it says it's Intel. I don't know if that would be an issue, maybe amd use intel sound drivers or something. It's odd nonetheless.

      \n\n

      This issue has been boggling my mind for obvious reasons, with half-rhetorical questions like does linux not support the earphones anymore, whether out of accident from an overlooked bug in an update or intentionally phasing out? Is any of this AMD or Lenovo's fault? Even with proper headphones or something, will they fail? I don't think anyone here really knows, hell I'd bet an extreme that no one really understands why in the linux community. I kinda rambled in this post with stuff that should've been said in the last post/thread, but I'm saying it now.

      \n\n

      Thanks for contributing thus far to this discussion in figuring this out.

      \n\n

      Specs: AMD Ryzen 5 3500U Mobile CPU (2.2 - 3.7ghz quad core)

      \n\n

      Radeon Vega 8 Integrated GPU, 8GB Ram, 256GB SSD.

      \n\n

      Lenovo C340-14API Laptop

      \n
      ", + "author": "BrianMeerkatlol", + "publication_date": "2020-07-21T21:02:19Z", + "url": "https://www.reddit.com/r/linux/comments/hvdi3o/observations_on_a_linux_issue_with_35mm_earphones/", + "read": false, + "rule": 80, + "remote_identifier": "hvdi3o" + } +}, +{ + "model": "core.post", + "pk": 3170, + "fields": { + "created": "2020-07-21T20:14:50.509Z", + "modified": "2020-07-21T20:14:50.549Z", + "title": "South Korean distro HamoniKR OS has been added to Distrowatch", + "body": "", + "author": "TheHordeRisesAgain", + "publication_date": "2020-07-21T07:44:21Z", + "url": "https://www.reddit.com/r/linux/comments/hv1ug1/south_korean_distro_hamonikr_os_has_been_added_to/", + "read": false, + "rule": 80, + "remote_identifier": "hv1ug1" + } +}, +{ + "model": "core.post", + "pk": 3171, + "fields": { + "created": "2020-07-21T20:14:50.509Z", + "modified": "2020-07-21T20:14:50.559Z", + "title": "The Jailer is free! New release of the outstanding database subsetter and browser is available.", + "body": "", + "author": "Plane-Discussion", + "publication_date": "2020-07-21T12:53:54Z", + "url": "https://www.reddit.com/r/linux/comments/hv5b0j/the_jailer_is_free_new_release_of_the_outstanding/", + "read": false, + "rule": 80, + "remote_identifier": "hv5b0j" + } +}, +{ + "model": "core.post", + "pk": 3172, + "fields": { + "created": "2020-07-21T20:14:50.513Z", + "modified": "2020-07-21T20:14:50.563Z", + "title": "A few very well-aged excerpts from Microsoft\u2019s infamous 2004 \u201cGet the facts\u201d campaign, where they make the case for Windows servers being cheaper, more secure, and more performant than Linux servers", + "body": "
      \n

      Get the facts on Windows and Linux.

      \n\n

      Leading companies and third-party analysts confirm it: Windows has a lower total cost of ownership and outperforms Linux.

      \n\n

      ...

      \n\n

      -Security

      \n\n

      Windows Users Have Fewer Vulnerabilities

      \n
      \n\n

      And then literally the very next bullet point:

      \n\n
      \n

      -Featured Customer Case Study

      \n\n

      Equifax

      \n\n

      Equifax Sees 14 Percent Cost Savings

      \n\n

      Find out why Equifax, a global leader in transforming data into intelligence, selected Windows over Linux to enhance the speed and performance of its marketing services capabilities. Using Microsoft Windows Server System, the company has seen 14 percent in cost savings over Linux.

      \n
      \n\n

      Good thing they saved 14% and got all that extra security! Sure their website is janky and their login flow is downright horrifying (Check it out if you want to be amazed), but who could blame them? Linux is \u201cProhibitively Expensive, Extremely Complex, and Provides No Tangible Business Gains\u201d, Microsoft said so!

      \n\n

      Source: https://web.archive.org/web/20041027003759/http://www.microsoft.com/windowsserversystem/facts/default.mspx

      \n
      ", + "author": "kevinhaze", + "publication_date": "2020-07-20T21:42:15Z", + "url": "https://www.reddit.com/r/linux/comments/hus5lz/a_few_very_wellaged_excerpts_from_microsofts/", + "read": false, + "rule": 80, + "remote_identifier": "hus5lz" + } +}, +{ + "model": "core.post", + "pk": 3173, + "fields": { + "created": "2020-07-21T20:14:50.515Z", + "modified": "2020-07-21T20:14:50.566Z", + "title": "Are there are any professional audio recording studios or artists that use Linux?", + "body": "

      As the title says, who is using Linux as a professional audio engineer, producer, or artist? I am a former Mac user myself, and I am seeing people from time to time who have become disillusioned with what Apple has been doing for the past few years. However, I'm not sure if Linux really has a place for these people to land if they are serious about what they do.

      \n\n

      Fedora Design Suite and Ubuntu Studio are definitely encouraging to see, but what is their real-world usage like? Are we getting better with professional audio in Linux, or have things been stagnant for years?

      \n
      ", + "author": "RootHouston", + "publication_date": "2020-07-21T00:08:26Z", + "url": "https://www.reddit.com/r/linux/comments/huuxvq/are_there_are_any_professional_audio_recording/", + "read": false, + "rule": 80, + "remote_identifier": "huuxvq" + } +}, +{ + "model": "core.post", + "pk": 3174, + "fields": { + "created": "2020-07-21T20:14:50.515Z", + "modified": "2020-07-21T20:14:50.570Z", + "title": "When Linux had marketing", + "body": "", + "author": "Commodore256", + "publication_date": "2020-07-21T14:03:56Z", + "url": "https://www.reddit.com/r/linux/comments/hv65oa/when_linux_had_marketing/", + "read": false, + "rule": 80, + "remote_identifier": "hv65oa" + } +}, +{ + "model": "core.post", + "pk": 3175, + "fields": { + "created": "2020-07-21T20:14:50.520Z", + "modified": "2020-07-21T20:14:50.598Z", + "title": "Ward: Simple and minimalistic server dashboard", + "body": "

      Ward is a simple and and minimalistic server monitoring tool. Ward supports adaptive design system. Also it supports dark theme. It shows only principal information and can be used, if you want to see nice looking dashboard instead looking on bunch of numbers and graphs. Ward works nice on all popular operating systems, because it uses OSHI.

      \n\n

      https://preview.redd.it/gdppswc3a3c51.png?width=1448&format=png&auto=webp&s=0d6e10146c105ddcfd045dd59c970d4c127ddb8c

      \n\n

      https://github.com/B-Software/Ward

      \n
      ", + "author": "Pabyzu", + "publication_date": "2020-07-21T00:33:40Z", + "url": "https://www.reddit.com/r/linux/comments/huvea3/ward_simple_and_minimalistic_server_dashboard/", + "read": false, + "rule": 80, + "remote_identifier": "huvea3" + } +}, +{ + "model": "core.post", + "pk": 3176, + "fields": { + "created": "2020-07-21T20:14:50.522Z", + "modified": "2020-07-21T20:14:50.606Z", + "title": "WindowsFX - a good Windows alternative?", + "body": "

      I would personally like to hear some of your opinions (in the replies) about WindowsFX. What is WindowsFX you may ask? WindowsFX is a Brazilian linux distribution that is designed to look and act like Windows 10.

      \n\n

      Linux / WindowsFX is based off of Ubuntu, and uses Cinnamon as its DE. Upon first boot, normal Windows users can tell the difference. But if you were to put it in front of a non tech-savvy person, they wouldn't be able to tell the difference.

      \n\n

      Personally, with WSL on Windows, I see no need for a distro like this. However, as I said, I would like to hear your opinions on this distro.

      \n\n

      Video review here.

      \n
      ", + "author": "Demonitized101", + "publication_date": "2020-07-20T23:03:29Z", + "url": "https://www.reddit.com/r/linux/comments/hutpt5/windowsfx_a_good_windows_alternative/", + "read": false, + "rule": 80, + "remote_identifier": "hutpt5" + } +}, +{ + "model": "core.post", + "pk": 3177, + "fields": { + "created": "2020-07-21T20:14:50.775Z", + "modified": "2020-07-21T20:14:50.780Z", + "title": "Every day this good boy brings a carrot to his best buddy", + "body": "
      ", + "author": "TooShiftyForYou", + "publication_date": "2020-07-21T15:25:31Z", + "url": "https://www.reddit.com/r/aww/comments/hv7a8b/every_day_this_good_boy_brings_a_carrot_to_his/", + "read": false, + "rule": 81, + "remote_identifier": "hv7a8b" + } +}, +{ + "model": "core.post", + "pk": 3178, + "fields": { + "created": "2020-07-21T20:14:50.775Z", + "modified": "2020-07-25T20:08:34.264Z", + "title": "Kitten mimics his human petting the dog", + "body": "
      ", + "author": "SpecterAscendant", + "publication_date": "2020-07-21T14:56:57Z", + "url": "https://www.reddit.com/r/aww/comments/hv6ve3/kitten_mimics_his_human_petting_the_dog/", + "read": true, + "rule": 81, + "remote_identifier": "hv6ve3" + } +}, +{ + "model": "core.post", + "pk": 3179, + "fields": { + "created": "2020-07-21T20:14:50.775Z", + "modified": "2020-07-21T20:14:50.789Z", + "title": "My fox friend!", + "body": "
      ", + "author": "Zepantha", + "publication_date": "2020-07-21T14:27:25Z", + "url": "https://www.reddit.com/r/aww/comments/hv6gte/my_fox_friend/", + "read": false, + "rule": 81, + "remote_identifier": "hv6gte" + } +}, +{ + "model": "core.post", + "pk": 3180, + "fields": { + "created": "2020-07-21T20:14:50.775Z", + "modified": "2020-07-21T20:15:46.876Z", + "title": "Ducks annihilate peas", + "body": "
      ", + "author": "tommycalibre", + "publication_date": "2020-07-21T17:12:40Z", + "url": "https://www.reddit.com/r/aww/comments/hv9258/ducks_annihilate_peas/", + "read": true, + "rule": 81, + "remote_identifier": "hv9258" + } +}, +{ + "model": "core.post", + "pk": 3181, + "fields": { + "created": "2020-07-21T20:14:50.775Z", + "modified": "2020-07-21T20:14:50.797Z", + "title": "Wiggle it baby", + "body": "
      ", + "author": "neo_star", + "publication_date": "2020-07-21T18:44:31Z", + "url": "https://www.reddit.com/r/aww/comments/hvaucy/wiggle_it_baby/", + "read": false, + "rule": 81, + "remote_identifier": "hvaucy" + } +}, +{ + "model": "core.post", + "pk": 3182, + "fields": { + "created": "2020-07-21T20:14:50.776Z", + "modified": "2020-07-21T20:16:22.725Z", + "title": "I guess I should do this.. everyone seems to be liking little pups and kittens so.. Reddit, meet bailey", + "body": "
      \"I
      ", + "author": "X_XNOTHINGX_X", + "publication_date": "2020-07-21T14:15:08Z", + "url": "https://www.reddit.com/r/aww/comments/hv6b0a/i_guess_i_should_do_this_everyone_seems_to_be/", + "read": true, + "rule": 81, + "remote_identifier": "hv6b0a" + } +}, +{ + "model": "core.post", + "pk": 3183, + "fields": { + "created": "2020-07-21T20:14:50.776Z", + "modified": "2020-07-21T20:14:50.806Z", + "title": "The hat makes the crab.", + "body": "
      \"The
      ", + "author": "fujfuj", + "publication_date": "2020-07-21T14:48:40Z", + "url": "https://www.reddit.com/r/aww/comments/hv6rde/the_hat_makes_the_crab/", + "read": false, + "rule": 81, + "remote_identifier": "hv6rde" + } +}, +{ + "model": "core.post", + "pk": 3184, + "fields": { + "created": "2020-07-21T20:14:50.776Z", + "modified": "2020-07-21T20:14:50.812Z", + "title": "Baby bunny fits in hand", + "body": "
      ", + "author": "Hawken10", + "publication_date": "2020-07-21T12:31:30Z", + "url": "https://www.reddit.com/r/aww/comments/hv5253/baby_bunny_fits_in_hand/", + "read": false, + "rule": 81, + "remote_identifier": "hv5253" + } +}, +{ + "model": "core.post", + "pk": 3185, + "fields": { + "created": "2020-07-21T20:14:50.776Z", + "modified": "2020-07-21T20:14:50.818Z", + "title": "My cat and I, both pregnant", + "body": "
      \"My
      ", + "author": "nixdionisio", + "publication_date": "2020-07-21T11:06:25Z", + "url": "https://www.reddit.com/r/aww/comments/hv44m2/my_cat_and_i_both_pregnant/", + "read": false, + "rule": 81, + "remote_identifier": "hv44m2" + } +}, +{ + "model": "core.post", + "pk": 3186, + "fields": { + "created": "2020-07-21T20:14:50.776Z", + "modified": "2020-07-21T20:14:50.822Z", + "title": "Very sweet dance", + "body": "
      ", + "author": "Ashley1023", + "publication_date": "2020-07-21T13:03:03Z", + "url": "https://www.reddit.com/r/aww/comments/hv5ewq/very_sweet_dance/", + "read": false, + "rule": 81, + "remote_identifier": "hv5ewq" + } +}, +{ + "model": "core.post", + "pk": 3187, + "fields": { + "created": "2020-07-21T20:14:50.776Z", + "modified": "2020-07-21T20:14:50.825Z", + "title": "My local pet-store has a cat named Vegemite \u2764\ufe0f", + "body": "
      \"My
      ", + "author": "galinhad", + "publication_date": "2020-07-21T12:06:17Z", + "url": "https://www.reddit.com/r/aww/comments/hv4s5z/my_local_petstore_has_a_cat_named_vegemite/", + "read": false, + "rule": 81, + "remote_identifier": "hv4s5z" + } +}, +{ + "model": "core.post", + "pk": 3188, + "fields": { + "created": "2020-07-21T20:14:50.777Z", + "modified": "2020-07-21T20:15:01.459Z", + "title": "A teacher like that makes a huge difference", + "body": "
      ", + "author": "Unicornglitteryblood", + "publication_date": "2020-07-21T18:29:57Z", + "url": "https://www.reddit.com/r/aww/comments/hvajo9/a_teacher_like_that_makes_a_huge_difference/", + "read": true, + "rule": 81, + "remote_identifier": "hvajo9" + } +}, +{ + "model": "core.post", + "pk": 3189, + "fields": { + "created": "2020-07-21T20:14:50.777Z", + "modified": "2020-07-22T19:55:49.930Z", + "title": "Kitten Encounters Bubbly Water", + "body": "
      \"Kitten
      ", + "author": "DragonOBunny", + "publication_date": "2020-07-21T15:28:05Z", + "url": "https://www.reddit.com/r/aww/comments/hv7bis/kitten_encounters_bubbly_water/", + "read": true, + "rule": 81, + "remote_identifier": "hv7bis" + } +}, +{ + "model": "core.post", + "pk": 3190, + "fields": { + "created": "2020-07-21T20:14:50.777Z", + "modified": "2020-07-21T20:14:50.833Z", + "title": "Are These My Chickens Now?", + "body": "", + "author": "jasontaken", + "publication_date": "2020-07-21T09:55:36Z", + "url": "https://www.reddit.com/r/aww/comments/hv3de1/are_these_my_chickens_now/", + "read": false, + "rule": 81, + "remote_identifier": "hv3de1" + } +}, +{ + "model": "core.post", + "pk": 3191, + "fields": { + "created": "2020-07-21T20:14:50.777Z", + "modified": "2020-07-25T20:08:20.518Z", + "title": "Our St Bernard 6 months apart", + "body": "
      \"Our
      ", + "author": "ryan3105", + "publication_date": "2020-07-21T18:00:04Z", + "url": "https://www.reddit.com/r/aww/comments/hv9yea/our_st_bernard_6_months_apart/", + "read": true, + "rule": 81, + "remote_identifier": "hv9yea" + } +}, +{ + "model": "core.post", + "pk": 3192, + "fields": { + "created": "2020-07-21T20:14:50.777Z", + "modified": "2020-07-21T20:14:50.837Z", + "title": "Father and child in sync", + "body": "
      ", + "author": "Araragi_Monogatari", + "publication_date": "2020-07-21T08:29:18Z", + "url": "https://www.reddit.com/r/aww/comments/hv2enj/father_and_child_in_sync/", + "read": false, + "rule": 81, + "remote_identifier": "hv2enj" + } +}, +{ + "model": "core.post", + "pk": 3193, + "fields": { + "created": "2020-07-21T20:14:50.778Z", + "modified": "2020-07-21T20:14:50.840Z", + "title": "A meme is born", + "body": "
      \"A
      ", + "author": "Unicornglitteryblood", + "publication_date": "2020-07-21T18:55:04Z", + "url": "https://www.reddit.com/r/aww/comments/hvb1vh/a_meme_is_born/", + "read": false, + "rule": 81, + "remote_identifier": "hvb1vh" + } +}, +{ + "model": "core.post", + "pk": 3194, + "fields": { + "created": "2020-07-21T20:14:50.778Z", + "modified": "2020-07-21T20:14:50.842Z", + "title": "She bites, then she sleeps, then bites again, then sleeps again. \ud83d\ude02", + "body": "
      ", + "author": "earlymauvs", + "publication_date": "2020-07-21T11:34:19Z", + "url": "https://www.reddit.com/r/aww/comments/hv4fat/she_bites_then_she_sleeps_then_bites_again_then/", + "read": false, + "rule": 81, + "remote_identifier": "hv4fat" + } +}, +{ + "model": "core.post", + "pk": 3195, + "fields": { + "created": "2020-07-21T20:14:50.778Z", + "modified": "2020-07-21T20:14:50.844Z", + "title": "Nothing calmer that 2 ginger cats rubbing heads and showing their love in morning", + "body": "
      \"Nothing
      ", + "author": "Apotheosis33", + "publication_date": "2020-07-21T08:39:24Z", + "url": "https://www.reddit.com/r/aww/comments/hv2j2g/nothing_calmer_that_2_ginger_cats_rubbing_heads/", + "read": false, + "rule": 81, + "remote_identifier": "hv2j2g" + } +}, +{ + "model": "core.post", + "pk": 3196, + "fields": { + "created": "2020-07-21T20:14:50.778Z", + "modified": "2020-07-21T20:14:50.851Z", + "title": "Ring Tailed Possum", + "body": "", + "author": "Wayward-Delver", + "publication_date": "2020-07-21T11:23:51Z", + "url": "https://www.reddit.com/r/aww/comments/hv4b9e/ring_tailed_possum/", + "read": false, + "rule": 81, + "remote_identifier": "hv4b9e" + } +}, +{ + "model": "core.post", + "pk": 3197, + "fields": { + "created": "2020-07-21T20:14:50.778Z", + "modified": "2020-07-21T20:14:50.854Z", + "title": "Baby scooby in sad mood....", + "body": "
      \"Baby
      ", + "author": "deepanshuahiroo7", + "publication_date": "2020-07-21T15:12:23Z", + "url": "https://www.reddit.com/r/aww/comments/hv73ft/baby_scooby_in_sad_mood/", + "read": false, + "rule": 81, + "remote_identifier": "hv73ft" + } +}, +{ + "model": "core.post", + "pk": 3198, + "fields": { + "created": "2020-07-21T20:14:50.779Z", + "modified": "2020-07-21T20:14:50.856Z", + "title": "New friends!", + "body": "
      \"New
      ", + "author": "HelentotheKeller", + "publication_date": "2020-07-21T13:10:48Z", + "url": "https://www.reddit.com/r/aww/comments/hv5i6i/new_friends/", + "read": false, + "rule": 81, + "remote_identifier": "hv5i6i" + } +}, +{ + "model": "core.post", + "pk": 3199, + "fields": { + "created": "2020-07-21T20:14:50.779Z", + "modified": "2020-07-21T20:14:50.858Z", + "title": "When you haven't chewed anything for 1 second", + "body": "
      \"When
      ", + "author": "Tanay4", + "publication_date": "2020-07-21T10:26:53Z", + "url": "https://www.reddit.com/r/aww/comments/hv3pl0/when_you_havent_chewed_anything_for_1_second/", + "read": false, + "rule": 81, + "remote_identifier": "hv3pl0" + } +}, +{ + "model": "core.post", + "pk": 3200, + "fields": { + "created": "2020-07-21T20:14:50.779Z", + "modified": "2020-07-21T20:17:01.490Z", + "title": "Mango Derp", + "body": "
      \"Mango
      ", + "author": "sheetglass", + "publication_date": "2020-07-21T13:27:26Z", + "url": "https://www.reddit.com/r/aww/comments/hv5p8s/mango_derp/", + "read": true, + "rule": 81, + "remote_identifier": "hv5p8s" + } +}, +{ + "model": "core.post", + "pk": 3201, + "fields": { + "created": "2020-07-21T20:14:50.779Z", + "modified": "2020-07-21T20:14:50.863Z", + "title": "My guy turns 20 next month", + "body": "
      \"My
      ", + "author": "alozsoc", + "publication_date": "2020-07-21T06:34:26Z", + "url": "https://www.reddit.com/r/aww/comments/hv0xp1/my_guy_turns_20_next_month/", + "read": false, + "rule": 81, + "remote_identifier": "hv0xp1" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add log entry", + "content_type": [ + "admin", + "logentry" + ], + "codename": "add_logentry" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change log entry", + "content_type": [ + "admin", + "logentry" + ], + "codename": "change_logentry" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete log entry", + "content_type": [ + "admin", + "logentry" + ], + "codename": "delete_logentry" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view log entry", + "content_type": [ + "admin", + "logentry" + ], + "codename": "view_logentry" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add permission", + "content_type": [ + "auth", + "permission" + ], + "codename": "add_permission" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change permission", + "content_type": [ + "auth", + "permission" + ], + "codename": "change_permission" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete permission", + "content_type": [ + "auth", + "permission" + ], + "codename": "delete_permission" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view permission", + "content_type": [ + "auth", + "permission" + ], + "codename": "view_permission" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add group", + "content_type": [ + "auth", + "group" + ], + "codename": "add_group" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change group", + "content_type": [ + "auth", + "group" + ], + "codename": "change_group" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete group", + "content_type": [ + "auth", + "group" + ], + "codename": "delete_group" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view group", + "content_type": [ + "auth", + "group" + ], + "codename": "view_group" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add content type", + "content_type": [ + "contenttypes", + "contenttype" + ], + "codename": "add_contenttype" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change content type", + "content_type": [ + "contenttypes", + "contenttype" + ], + "codename": "change_contenttype" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete content type", + "content_type": [ + "contenttypes", + "contenttype" + ], + "codename": "delete_contenttype" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view content type", + "content_type": [ + "contenttypes", + "contenttype" + ], + "codename": "view_contenttype" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add session", + "content_type": [ + "sessions", + "session" + ], + "codename": "add_session" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change session", + "content_type": [ + "sessions", + "session" + ], + "codename": "change_session" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete session", + "content_type": [ + "sessions", + "session" + ], + "codename": "delete_session" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view session", + "content_type": [ + "sessions", + "session" + ], + "codename": "view_session" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add crontab", + "content_type": [ + "django_celery_beat", + "crontabschedule" + ], + "codename": "add_crontabschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change crontab", + "content_type": [ + "django_celery_beat", + "crontabschedule" + ], + "codename": "change_crontabschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete crontab", + "content_type": [ + "django_celery_beat", + "crontabschedule" + ], + "codename": "delete_crontabschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view crontab", + "content_type": [ + "django_celery_beat", + "crontabschedule" + ], + "codename": "view_crontabschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add interval", + "content_type": [ + "django_celery_beat", + "intervalschedule" + ], + "codename": "add_intervalschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change interval", + "content_type": [ + "django_celery_beat", + "intervalschedule" + ], + "codename": "change_intervalschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete interval", + "content_type": [ + "django_celery_beat", + "intervalschedule" + ], + "codename": "delete_intervalschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view interval", + "content_type": [ + "django_celery_beat", + "intervalschedule" + ], + "codename": "view_intervalschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add periodic task", + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "codename": "add_periodictask" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change periodic task", + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "codename": "change_periodictask" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete periodic task", + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "codename": "delete_periodictask" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view periodic task", + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "codename": "view_periodictask" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add periodic tasks", + "content_type": [ + "django_celery_beat", + "periodictasks" + ], + "codename": "add_periodictasks" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change periodic tasks", + "content_type": [ + "django_celery_beat", + "periodictasks" + ], + "codename": "change_periodictasks" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete periodic tasks", + "content_type": [ + "django_celery_beat", + "periodictasks" + ], + "codename": "delete_periodictasks" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view periodic tasks", + "content_type": [ + "django_celery_beat", + "periodictasks" + ], + "codename": "view_periodictasks" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add solar event", + "content_type": [ + "django_celery_beat", + "solarschedule" + ], + "codename": "add_solarschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change solar event", + "content_type": [ + "django_celery_beat", + "solarschedule" + ], + "codename": "change_solarschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete solar event", + "content_type": [ + "django_celery_beat", + "solarschedule" + ], + "codename": "delete_solarschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view solar event", + "content_type": [ + "django_celery_beat", + "solarschedule" + ], + "codename": "view_solarschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add clocked", + "content_type": [ + "django_celery_beat", + "clockedschedule" + ], + "codename": "add_clockedschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change clocked", + "content_type": [ + "django_celery_beat", + "clockedschedule" + ], + "codename": "change_clockedschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete clocked", + "content_type": [ + "django_celery_beat", + "clockedschedule" + ], + "codename": "delete_clockedschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view clocked", + "content_type": [ + "django_celery_beat", + "clockedschedule" + ], + "codename": "view_clockedschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add registration profile", + "content_type": [ + "registration", + "registrationprofile" + ], + "codename": "add_registrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change registration profile", + "content_type": [ + "registration", + "registrationprofile" + ], + "codename": "change_registrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete registration profile", + "content_type": [ + "registration", + "registrationprofile" + ], + "codename": "delete_registrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view registration profile", + "content_type": [ + "registration", + "registrationprofile" + ], + "codename": "view_registrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add supervised registration profile", + "content_type": [ + "registration", + "supervisedregistrationprofile" + ], + "codename": "add_supervisedregistrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change supervised registration profile", + "content_type": [ + "registration", + "supervisedregistrationprofile" + ], + "codename": "change_supervisedregistrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete supervised registration profile", + "content_type": [ + "registration", + "supervisedregistrationprofile" + ], + "codename": "delete_supervisedregistrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view supervised registration profile", + "content_type": [ + "registration", + "supervisedregistrationprofile" + ], + "codename": "view_supervisedregistrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add access attempt", + "content_type": [ + "axes", + "accessattempt" + ], + "codename": "add_accessattempt" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change access attempt", + "content_type": [ + "axes", + "accessattempt" + ], + "codename": "change_accessattempt" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete access attempt", + "content_type": [ + "axes", + "accessattempt" + ], + "codename": "delete_accessattempt" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view access attempt", + "content_type": [ + "axes", + "accessattempt" + ], + "codename": "view_accessattempt" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add access log", + "content_type": [ + "axes", + "accesslog" + ], + "codename": "add_accesslog" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change access log", + "content_type": [ + "axes", + "accesslog" + ], + "codename": "change_accesslog" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete access log", + "content_type": [ + "axes", + "accesslog" + ], + "codename": "delete_accesslog" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view access log", + "content_type": [ + "axes", + "accesslog" + ], + "codename": "view_accesslog" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add user", + "content_type": [ + "accounts", + "user" + ], + "codename": "add_user" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change user", + "content_type": [ + "accounts", + "user" + ], + "codename": "change_user" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete user", + "content_type": [ + "accounts", + "user" + ], + "codename": "delete_user" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view user", + "content_type": [ + "accounts", + "user" + ], + "codename": "view_user" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add post", + "content_type": [ + "core", + "post" + ], + "codename": "add_post" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change post", + "content_type": [ + "core", + "post" + ], + "codename": "change_post" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete post", + "content_type": [ + "core", + "post" + ], + "codename": "delete_post" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view post", + "content_type": [ + "core", + "post" + ], + "codename": "view_post" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add Category", + "content_type": [ + "core", + "category" + ], + "codename": "add_category" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change Category", + "content_type": [ + "core", + "category" + ], + "codename": "change_category" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete Category", + "content_type": [ + "core", + "category" + ], + "codename": "delete_category" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view Category", + "content_type": [ + "core", + "category" + ], + "codename": "view_category" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add collection rule", + "content_type": [ + "collection", + "collectionrule" + ], + "codename": "add_collectionrule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change collection rule", + "content_type": [ + "collection", + "collectionrule" + ], + "codename": "change_collectionrule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete collection rule", + "content_type": [ + "collection", + "collectionrule" + ], + "codename": "delete_collectionrule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view collection rule", + "content_type": [ + "collection", + "collectionrule" + ], + "codename": "view_collectionrule" + } +}, +{ + "model": "accounts.user", + "fields": { + "password": "pbkdf2_sha256$180000$U9a2CS9X0b8Y$T6bD/VoUOFoGNIp16aFlOL0N7q0e6A3I97ypm/AhsGo=", + "last_login": "2020-07-21T20:14:35.966Z", + "is_superuser": true, + "first_name": "", + "last_name": "", + "is_staff": true, + "is_active": true, + "date_joined": "2019-07-18T18:52:36.080Z", + "email": "sonny@bakker.nl", + "task": 10, + "reddit_refresh_token": null, + "reddit_access_token": null, + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "core.category", + "pk": 8, + "fields": { + "created": "2019-11-17T19:37:24.671Z", + "modified": "2019-11-18T19:59:55.010Z", + "name": "World news", + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "core.category", + "pk": 9, + "fields": { + "created": "2019-11-17T19:37:26.161Z", + "modified": "2020-05-30T13:36:10.509Z", + "name": "Tech", + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 3, + "fields": { + "created": "2019-07-14T13:08:10.374Z", + "modified": "2020-07-14T11:45:30.680Z", + "name": "Hackers News", + "type": "feed", + "url": "https://news.ycombinator.com/rss", + "website_url": "https://news.ycombinator.com/", + "favicon": "https://news.ycombinator.com/favicon.ico", + "timezone": "UTC", + "category": 9, + "last_suceeded": "2020-07-14T11:45:30.477Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 4, + "fields": { + "created": "2019-07-20T11:24:32.745Z", + "modified": "2020-07-14T11:45:29.357Z", + "name": "BBC", + "type": "feed", + "url": "http://feeds.bbci.co.uk/news/world/rss.xml", + "website_url": "https://www.bbc.co.uk/news/", + "favicon": "https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png", + "timezone": "UTC", + "category": 8, + "last_suceeded": "2020-07-14T11:45:28.863Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 5, + "fields": { + "created": "2019-07-20T11:24:50.411Z", + "modified": "2020-07-14T11:45:30.063Z", + "name": "Ars Technica", + "type": "feed", + "url": "http://feeds.arstechnica.com/arstechnica/index?fmt=xml", + "website_url": "https://arstechnica.com", + "favicon": "https://cdn.arstechnica.net/favicon.ico", + "timezone": "UTC", + "category": 9, + "last_suceeded": "2020-07-14T11:45:29.810Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 6, + "fields": { + "created": "2019-07-20T11:25:02.089Z", + "modified": "2020-07-14T11:45:30.473Z", + "name": "The Guardian", + "type": "feed", + "url": "https://www.theguardian.com/world/rss", + "website_url": "https://www.theguardian.com/world", + "favicon": "https://assets.guim.co.uk/images/favicons/873381bf11d58e20f551905d51575117/72x72.png", + "timezone": "UTC", + "category": 8, + "last_suceeded": "2020-07-14T11:45:30.181Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 7, + "fields": { + "created": "2019-07-20T11:25:30.121Z", + "modified": "2020-07-14T11:45:29.807Z", + "name": "Tweakers", + "type": "feed", + "url": "http://feeds.feedburner.com/tweakers/mixed?fmt=xml", + "website_url": "https://tweakers.net/", + "favicon": null, + "timezone": "UTC", + "category": 9, + "last_suceeded": "2020-07-14T11:45:29.525Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 8, + "fields": { + "created": "2019-07-20T11:25:46.256Z", + "modified": "2020-07-14T11:45:30.179Z", + "name": "The Verge", + "type": "feed", + "url": "https://www.theverge.com/rss/index.xml", + "website_url": "https://www.theverge.com/", + "favicon": "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395367/favicon-16x16.0.png", + "timezone": "UTC", + "category": 9, + "last_suceeded": "2020-07-14T11:45:30.066Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 9, + "fields": { + "created": "2019-11-24T15:28:41.399Z", + "modified": "2020-07-14T11:45:29.522Z", + "name": "NOS", + "type": "feed", + "url": "http://feeds.nos.nl/nosnieuwsalgemeen", + "website_url": null, + "favicon": null, + "timezone": "Europe/Amsterdam", + "category": 8, + "last_suceeded": "2020-07-14T11:45:29.362Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 80, + "fields": { + "created": "2020-07-08T19:30:10.638Z", + "modified": "2020-07-21T20:14:50.609Z", + "name": "Linux subreddit", + "type": "subreddit", + "url": "https://oauth.reddit.com/r/linux/hot", + "website_url": null, + "favicon": null, + "timezone": "UTC", + "category": 9, + "last_suceeded": "2020-07-21T20:14:50.492Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 81, + "fields": { + "created": "2020-07-08T19:30:33.590Z", + "modified": "2020-07-21T20:14:50.865Z", + "name": "AWW subreddit", + "type": "subreddit", + "url": "https://oauth.reddit.com/r/aww/hot", + "website_url": null, + "favicon": null, + "timezone": "UTC", + "category": 8, + "last_suceeded": "2020-07-21T20:14:50.768Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 82, + "fields": { + "created": "2020-07-20T19:29:37.675Z", + "modified": "2020-07-21T20:14:50.489Z", + "name": "Star citizen subreddit", + "type": "subreddit", + "url": "https://oauth.reddit.com/r/starcitizen/hot.json", + "website_url": null, + "favicon": null, + "timezone": "UTC", + "category": 9, + "last_suceeded": "2020-07-21T20:14:50.355Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "admin.logentry", + "pk": 1, + "fields": { + "action_time": "2020-05-24T18:38:44.624Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "django_celery_beat", + "intervalschedule" + ], + "object_id": "5", + "object_repr": "every 4 hours", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 2, + "fields": { + "action_time": "2020-05-24T18:38:46.689Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "object_id": "10", + "object_repr": "sonny@bakker.nl-collection-task: every 4 hours", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Interval Schedule\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 3, + "fields": { + "action_time": "2020-05-24T18:39:09.203Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "object_id": "26", + "object_repr": "sonnyba871@gmail.com-collection-task: every hour", + "action_flag": 3, + "change_message": "" + } +}, +{ + "model": "admin.logentry", + "pk": 4, + "fields": { + "action_time": "2020-05-24T19:46:50.248Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "object_id": "10", + "object_repr": "sonny@bakker.nl-collection-task: every 4 hours", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Positional Arguments\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 5, + "fields": { + "action_time": "2020-07-07T19:37:57.086Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "accounts", + "user" + ], + "object_id": "1", + "object_repr": "sonny@bakker.nl", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Reddit refresh token\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 6, + "fields": { + "action_time": "2020-07-07T19:39:46.160Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "object_id": "10", + "object_repr": "sonny@bakker.nl-collection-task: every 4 hours", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Task (registered)\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 7, + "fields": { + "action_time": "2020-07-08T19:29:27.025Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "object_id": "11", + "object_repr": "Reddit collection task: every 4 hours", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 8, + "fields": { + "action_time": "2020-07-14T11:46:50.039Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "accounts", + "user" + ], + "object_id": "1", + "object_repr": "sonny@bakker.nl", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Reddit access token\", \"Reddit refresh token\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 9, + "fields": { + "action_time": "2020-07-18T19:08:33.997Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "collection", + "collectionrule" + ], + "object_id": "81", + "object_repr": "AWW subreddit", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Url\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 10, + "fields": { + "action_time": "2020-07-18T19:08:44.063Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "collection", + "collectionrule" + ], + "object_id": "80", + "object_repr": "Linux subreddit", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Url\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 11, + "fields": { + "action_time": "2020-07-18T19:17:25.213Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "core", + "post" + ], + "object_id": "2336", + "object_repr": "Post-2336", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 12, + "fields": { + "action_time": "2020-07-18T19:17:40.596Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "core", + "post" + ], + "object_id": "2336", + "object_repr": "Post-2336", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 13, + "fields": { + "action_time": "2020-07-19T10:55:55.807Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "core", + "post" + ], + "object_id": "2764", + "object_repr": "Post-2764", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 14, + "fields": { + "action_time": "2020-07-19T10:57:40.643Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "core", + "post" + ], + "object_id": "2764", + "object_repr": "Post-2764", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 15, + "fields": { + "action_time": "2020-07-19T10:58:05.823Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "core", + "post" + ], + "object_id": "2764", + "object_repr": "Post-2764", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 16, + "fields": { + "action_time": "2020-07-26T09:51:52.478Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "accounts", + "user" + ], + "object_id": "1", + "object_repr": "sonny@bakker.nl", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"First name\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 17, + "fields": { + "action_time": "2020-07-26T09:52:04.691Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "accounts", + "user" + ], + "object_id": "1", + "object_repr": "sonny@bakker.nl", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"password\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 18, + "fields": { + "action_time": "2020-07-26T09:52:12.392Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "accounts", + "user" + ], + "object_id": "1", + "object_repr": "sonny@bakker.nl", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"First name\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 19, + "fields": { + "action_time": "2020-07-26T09:56:15.949Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "accounts", + "user" + ], + "object_id": "1", + "object_repr": "sonny@bakker.nl", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Reddit access token\", \"Reddit refresh token\"]}}]" + } +} +] From 2b106123041a6a27a816bdc74471b3cdabeafcca Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 26 Jul 2020 12:32:22 +0200 Subject: [PATCH 139/422] Add branding to admin --- src/newsreader/templates/admin/base_site.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/newsreader/templates/admin/base_site.html b/src/newsreader/templates/admin/base_site.html index c9d88b8..e29cef2 100644 --- a/src/newsreader/templates/admin/base_site.html +++ b/src/newsreader/templates/admin/base_site.html @@ -1,6 +1,10 @@ {% extends "admin/base.html" %} {% load static %} +{% block branding %} +

      Newsreader

      +{% endblock %} + {% block extrahead %} {% endblock %} From c04f23b3eeb203c7968382996c5ba68110ab1e48 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 26 Jul 2020 18:33:52 +0200 Subject: [PATCH 140/422] Dont't overwrite default checkbox This caused issues in the admin --- src/newsreader/core/forms.py | 9 +++++++++ src/newsreader/news/collection/forms.py | 5 ++++- .../news/core/templates/news/core/widgets/rule.html | 2 +- src/newsreader/templates/components/form/checkbox.html | 9 +++------ src/newsreader/templates/components/form/input.html | 2 +- src/newsreader/templates/django/forms/widgets/attrs.html | 1 - .../templates/django/forms/widgets/checkbox.html | 1 - 7 files changed, 18 insertions(+), 11 deletions(-) create mode 100644 src/newsreader/core/forms.py delete mode 120000 src/newsreader/templates/django/forms/widgets/attrs.html delete mode 120000 src/newsreader/templates/django/forms/widgets/checkbox.html diff --git a/src/newsreader/core/forms.py b/src/newsreader/core/forms.py new file mode 100644 index 0000000..ca3fe22 --- /dev/null +++ b/src/newsreader/core/forms.py @@ -0,0 +1,9 @@ +from django import forms + + +class CheckboxInput(forms.CheckboxInput): + template_name = "components/form/checkbox.html" + + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + return {**context, **attrs} diff --git a/src/newsreader/news/collection/forms.py b/src/newsreader/news/collection/forms.py index 604500d..c79a867 100644 --- a/src/newsreader/news/collection/forms.py +++ b/src/newsreader/news/collection/forms.py @@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _ import pytz +from newsreader.core.forms import CheckboxInput from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.models import CollectionRule from newsreader.news.collection.reddit import REDDIT_API_URL @@ -95,4 +96,6 @@ class SubRedditRuleForm(CollectionRuleForm): class OPMLImportForm(forms.Form): file = forms.FileField(allow_empty_file=False) - skip_existing = forms.BooleanField(initial=False, required=False) + skip_existing = forms.BooleanField( + initial=False, required=False, widget=CheckboxInput + ) diff --git a/src/newsreader/news/core/templates/news/core/widgets/rule.html b/src/newsreader/news/core/templates/news/core/widgets/rule.html index c8535e8..beebe29 100644 --- a/src/newsreader/news/core/templates/news/core/widgets/rule.html +++ b/src/newsreader/news/core/templates/news/core/widgets/rule.html @@ -1,7 +1,7 @@ {% load filters %} {% with option.instance|id_for_label:"category" as id_for_label %} - {% include "components/form/checkbox.html" with widget=option checked=option.selected id_for_label=id_for_label only %} + {% include "components/form/checkbox.html" with name=option.name value=option.value checked=option.selected id=id_for_label only %} {% endwith %} {% if option.instance.favicon %} diff --git a/src/newsreader/templates/components/form/checkbox.html b/src/newsreader/templates/components/form/checkbox.html index 42ac691..c36d5f6 100644 --- a/src/newsreader/templates/components/form/checkbox.html +++ b/src/newsreader/templates/components/form/checkbox.html @@ -1,10 +1,7 @@
      - {% if widget %} - {% include "components/form/input.html" with widget=widget %} - {% else %} - {% include "components/form/input.html" with id=id name=name type="checkbox" value=value data_input=data_input checked=checked %} - {% endif %} -
      diff --git a/src/newsreader/templates/components/form/input.html b/src/newsreader/templates/components/form/input.html index 08f32e1..1ecfaaf 100644 --- a/src/newsreader/templates/components/form/input.html +++ b/src/newsreader/templates/components/form/input.html @@ -3,5 +3,5 @@ type="{{ widget.type }}" name="{{ widget.name }}" {% if widget.value != None %} value="{{ widget.value|stringformat:'s' }}"{% endif %} {% endif %} - {% include "django/forms/widgets/attrs.html" %}> + {% include "components/form/attrs.html" %}> {% endspaceless %} diff --git a/src/newsreader/templates/django/forms/widgets/attrs.html b/src/newsreader/templates/django/forms/widgets/attrs.html deleted file mode 120000 index 595204e..0000000 --- a/src/newsreader/templates/django/forms/widgets/attrs.html +++ /dev/null @@ -1 +0,0 @@ -../../../components/form/attrs.html \ No newline at end of file diff --git a/src/newsreader/templates/django/forms/widgets/checkbox.html b/src/newsreader/templates/django/forms/widgets/checkbox.html deleted file mode 120000 index f939869..0000000 --- a/src/newsreader/templates/django/forms/widgets/checkbox.html +++ /dev/null @@ -1 +0,0 @@ -../../../components/form/checkbox.html \ No newline at end of file From 629b35b69b247ab38ff270ee61703da7da282726 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 26 Jul 2020 18:39:49 +0200 Subject: [PATCH 141/422] Update post modal list styling Fixes #60 --- src/newsreader/scss/components/post/_post.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/newsreader/scss/components/post/_post.scss b/src/newsreader/scss/components/post/_post.scss index 6b41844..9374f39 100644 --- a/src/newsreader/scss/components/post/_post.scss +++ b/src/newsreader/scss/components/post/_post.scss @@ -72,6 +72,10 @@ padding: 10px 0; max-width: 100%; } + + & ul, ol { + list-style-position: inside; + } } &__close-button { From 6a13c587125acf6077ba4d40762772ed86c66b79 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 26 Jul 2020 19:02:51 +0200 Subject: [PATCH 142/422] Show icons instead of boolean values in rules table Fixes #58 --- .../templates/news/collection/views/rules.html | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/newsreader/news/collection/templates/news/collection/views/rules.html b/src/newsreader/news/collection/templates/news/collection/views/rules.html index b8ab514..145e154 100644 --- a/src/newsreader/news/collection/templates/news/collection/views/rules.html +++ b/src/newsreader/news/collection/templates/news/collection/views/rules.html @@ -46,8 +46,20 @@ {{ rule.name }} {{ rule.category.name }} {{ rule.url }} - {{ rule.succeeded }} - {{ rule.enabled }} + + {% if rule.succeeded %} + + {% else %} + + {% endif %} + + + {% if rule.enabled %} + + {% else %} + + {% endif %} + From fd16478909d40d718a57ac8ed9cbb17b5535ad25 Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 26 Jul 2020 19:41:17 +0200 Subject: [PATCH 143/422] Update rule table styling --- .../news/collection/views/rules.html | 22 +++++++++++-------- .../scss/components/table/_rules-table.scss | 13 +---------- .../scss/components/table/_table.scss | 9 ++++++-- 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/src/newsreader/news/collection/templates/news/collection/views/rules.html b/src/newsreader/news/collection/templates/news/collection/views/rules.html index 145e154..0cd1870 100644 --- a/src/newsreader/news/collection/templates/news/collection/views/rules.html +++ b/src/newsreader/news/collection/templates/news/collection/views/rules.html @@ -21,7 +21,7 @@
      - +
      - {% for rule in rules %} - + - - - + + + - {% endfor %} diff --git a/src/newsreader/scss/components/table/_rules-table.scss b/src/newsreader/scss/components/table/_rules-table.scss index 3eaf3b3..3be0430 100644 --- a/src/newsreader/scss/components/table/_rules-table.scss +++ b/src/newsreader/scss/components/table/_rules-table.scss @@ -5,7 +5,7 @@ } &--name { - width: 20%; + width: 25%; } &--category { @@ -23,16 +23,5 @@ &--enabled { width: 10%; } - - &--link { - width: 5%; - } - } - - & .link { - display: flex; - justify-content: center; - - padding: 10px; } } diff --git a/src/newsreader/scss/components/table/_table.scss b/src/newsreader/scss/components/table/_table.scss index 01f81a0..5595048 100644 --- a/src/newsreader/scss/components/table/_table.scss +++ b/src/newsreader/scss/components/table/_table.scss @@ -2,6 +2,7 @@ .table { table-layout: fixed; + background-color: $white; width: 90%; padding: 20px; @@ -13,11 +14,15 @@ @extend .h1; } + &__row { + &--error { + background-color: lighten($error-red, 25%); + } + } + &__item { padding: 10px 0; - border-bottom: 1px solid $border-gray; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; From a1c11baa3d6a6b3680ebf592c3be9f3972a3f9ff Mon Sep 17 00:00:00 2001 From: Sonny Date: Sun, 26 Jul 2020 21:23:19 +0200 Subject: [PATCH 144/422] Update table row error styling --- src/newsreader/scss/components/table/_table.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/newsreader/scss/components/table/_table.scss b/src/newsreader/scss/components/table/_table.scss index 5595048..69bb298 100644 --- a/src/newsreader/scss/components/table/_table.scss +++ b/src/newsreader/scss/components/table/_table.scss @@ -16,7 +16,7 @@ &__row { &--error { - background-color: lighten($error-red, 25%); + background-color: transparentize($error-red, 0.8); } } From b25cdcf4db9c82bd7e42e087ed81ea750ccde954 Mon Sep 17 00:00:00 2001 From: sonny Date: Sun, 26 Jul 2020 21:45:51 +0200 Subject: [PATCH 145/422] Fix category action test This was the same test as before -.- --- src/newsreader/accounts/admin.py | 8 +- src/newsreader/core/forms.py | 9 + src/newsreader/fixtures/default-fixture.json | 6863 ++++++++++------- src/newsreader/news/collection/forms.py | 5 +- .../news/collection/views/rules.html | 34 +- .../templates/news/core/widgets/rule.html | 2 +- .../scss/components/card/_card.scss | 1 - .../scss/components/errorlist/_errorlist.scss | 1 - .../scss/components/form/_form.scss | 1 - .../scss/components/messages/_messages.scss | 2 - .../scss/components/modal/_post-modal.scss | 1 - .../scss/components/post/_post.scss | 4 + .../components/section/_text-section.scss | 1 - .../scss/components/sidebar/_sidebar.scss | 1 - .../scss/components/table/_rules-table.scss | 13 +- .../scss/components/table/_table.scss | 9 +- src/newsreader/scss/pages/login/index.scss | 2 - src/newsreader/templates/admin/base_site.html | 4 + .../templates/components/form/checkbox.html | 9 +- .../templates/components/form/input.html | 2 +- .../templates/django/forms/widgets/attrs.html | 1 - .../django/forms/widgets/checkbox.html | 1 - 22 files changed, 4087 insertions(+), 2887 deletions(-) create mode 100644 src/newsreader/core/forms.py delete mode 120000 src/newsreader/templates/django/forms/widgets/attrs.html delete mode 120000 src/newsreader/templates/django/forms/widgets/checkbox.html diff --git a/src/newsreader/accounts/admin.py b/src/newsreader/accounts/admin.py index e0b5eed..49390c7 100644 --- a/src/newsreader/accounts/admin.py +++ b/src/newsreader/accounts/admin.py @@ -1,11 +1,13 @@ from django import forms from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin +from django.contrib.auth.forms import UserChangeForm from django.utils.translation import ugettext as _ from newsreader.accounts.models import User -class UserAdminForm(forms.ModelForm): +class UserAdminForm(UserChangeForm): class Meta: widgets = { "email": forms.EmailInput(attrs={"size": "50"}), @@ -14,7 +16,7 @@ class UserAdminForm(forms.ModelForm): } -class UserAdmin(admin.ModelAdmin): +class UserAdmin(DjangoUserAdmin): list_display = ("email", "last_name", "date_joined", "is_active") list_filter = ("is_active", "is_staff", "is_superuser") ordering = ("email",) @@ -26,7 +28,7 @@ class UserAdmin(admin.ModelAdmin): fieldsets = ( ( _("User settings"), - {"fields": ("email", "first_name", "last_name", "is_active")}, + {"fields": ("email", "password", "first_name", "last_name", "is_active")}, ), ( _("Reddit settings"), diff --git a/src/newsreader/core/forms.py b/src/newsreader/core/forms.py new file mode 100644 index 0000000..ca3fe22 --- /dev/null +++ b/src/newsreader/core/forms.py @@ -0,0 +1,9 @@ +from django import forms + + +class CheckboxInput(forms.CheckboxInput): + template_name = "components/form/checkbox.html" + + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + return {**context, **attrs} diff --git a/src/newsreader/fixtures/default-fixture.json b/src/newsreader/fixtures/default-fixture.json index 4117cc1..10d6416 100644 --- a/src/newsreader/fixtures/default-fixture.json +++ b/src/newsreader/fixtures/default-fixture.json @@ -1,2840 +1,4023 @@ -[ -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "admin", - "model": "logentry" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "auth", - "model": "permission" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "auth", - "model": "group" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "contenttypes", - "model": "contenttype" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "sessions", - "model": "session" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "django_celery_beat", - "model": "crontabschedule" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "django_celery_beat", - "model": "intervalschedule" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "django_celery_beat", - "model": "periodictask" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "django_celery_beat", - "model": "periodictasks" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "django_celery_beat", - "model": "solarschedule" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "django_celery_beat", - "model": "clockedschedule" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "registration", - "model": "registrationprofile" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "registration", - "model": "supervisedregistrationprofile" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "axes", - "model": "accessattempt" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "axes", - "model": "accesslog" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "accounts", - "model": "user" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "core", - "model": "post" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "core", - "model": "category" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "collection", - "model": "collectionrule" - } -}, -{ - "model": "sessions.session", - "pk": "3sumq22krk8tsvexcs4b8czu82yhvuer", - "fields": { - "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", - "expire_date": "2020-05-16T18:29:04.049Z" - } -}, -{ - "model": "sessions.session", - "pk": "d4wophwpjm8z96doe8iddvhdv9yfafyx", - "fields": { - "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", - "expire_date": "2020-06-07T19:45:49.727Z" - } -}, -{ - "model": "sessions.session", - "pk": "jwn66dptmdkm6hom2ns3j288aaxqtyjd", - "fields": { - "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", - "expire_date": "2020-06-07T18:38:19.116Z" - } -}, -{ - "model": "django_celery_beat.intervalschedule", - "pk": 1, - "fields": { - "every": 5, - "period": "minutes" - } -}, -{ - "model": "django_celery_beat.intervalschedule", - "pk": 2, - "fields": { - "every": 15, - "period": "minutes" - } -}, -{ - "model": "django_celery_beat.intervalschedule", - "pk": 3, - "fields": { - "every": 30, - "period": "minutes" - } -}, -{ - "model": "django_celery_beat.intervalschedule", - "pk": 4, - "fields": { - "every": 1, - "period": "hours" - } -}, -{ - "model": "django_celery_beat.intervalschedule", - "pk": 5, - "fields": { - "every": 4, - "period": "hours" - } -}, -{ - "model": "django_celery_beat.crontabschedule", - "pk": 1, - "fields": { - "minute": "0", - "hour": "4", - "day_of_week": "*", - "day_of_month": "*", - "month_of_year": "*", - "timezone": "UTC" - } -}, -{ - "model": "django_celery_beat.periodictasks", - "pk": 1, - "fields": { - "last_update": "2020-05-24T19:46:50.243Z" - } -}, -{ - "model": "django_celery_beat.periodictask", - "pk": 1, - "fields": { - "name": "celery.backend_cleanup", - "task": "celery.backend_cleanup", - "interval": null, - "crontab": 1, - "solar": null, - "clocked": null, - "args": "[]", - "kwargs": "{}", - "queue": null, - "exchange": null, - "routing_key": null, - "headers": "{}", - "priority": null, - "expires": null, - "expire_seconds": 43200, - "one_off": false, - "start_time": null, - "enabled": true, - "last_run_at": null, - "total_run_count": 0, - "date_changed": "2020-05-02T20:06:23.985Z", - "description": "" - } -}, -{ - "model": "django_celery_beat.periodictask", - "pk": 10, - "fields": { - "name": "sonny@bakker.nl-collection-task", - "task": "newsreader.news.collection.tasks.FeedTask", - "interval": 5, - "crontab": null, - "solar": null, - "clocked": null, - "args": "[1]", - "kwargs": "{}", - "queue": null, - "exchange": null, - "routing_key": null, - "headers": "{}", - "priority": null, - "expires": null, - "expire_seconds": null, - "one_off": false, - "start_time": null, - "enabled": true, - "last_run_at": "2020-05-24T18:37:57.707Z", - "total_run_count": 293, - "date_changed": "2020-05-24T19:46:50.245Z", - "description": "" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add log entry", - "content_type": [ - "admin", - "logentry" - ], - "codename": "add_logentry" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change log entry", - "content_type": [ - "admin", - "logentry" - ], - "codename": "change_logentry" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete log entry", - "content_type": [ - "admin", - "logentry" - ], - "codename": "delete_logentry" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view log entry", - "content_type": [ - "admin", - "logentry" - ], - "codename": "view_logentry" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add permission", - "content_type": [ - "auth", - "permission" - ], - "codename": "add_permission" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change permission", - "content_type": [ - "auth", - "permission" - ], - "codename": "change_permission" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete permission", - "content_type": [ - "auth", - "permission" - ], - "codename": "delete_permission" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view permission", - "content_type": [ - "auth", - "permission" - ], - "codename": "view_permission" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add group", - "content_type": [ - "auth", - "group" - ], - "codename": "add_group" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change group", - "content_type": [ - "auth", - "group" - ], - "codename": "change_group" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete group", - "content_type": [ - "auth", - "group" - ], - "codename": "delete_group" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view group", - "content_type": [ - "auth", - "group" - ], - "codename": "view_group" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add content type", - "content_type": [ - "contenttypes", - "contenttype" - ], - "codename": "add_contenttype" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change content type", - "content_type": [ - "contenttypes", - "contenttype" - ], - "codename": "change_contenttype" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete content type", - "content_type": [ - "contenttypes", - "contenttype" - ], - "codename": "delete_contenttype" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view content type", - "content_type": [ - "contenttypes", - "contenttype" - ], - "codename": "view_contenttype" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add session", - "content_type": [ - "sessions", - "session" - ], - "codename": "add_session" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change session", - "content_type": [ - "sessions", - "session" - ], - "codename": "change_session" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete session", - "content_type": [ - "sessions", - "session" - ], - "codename": "delete_session" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view session", - "content_type": [ - "sessions", - "session" - ], - "codename": "view_session" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add crontab", - "content_type": [ - "django_celery_beat", - "crontabschedule" - ], - "codename": "add_crontabschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change crontab", - "content_type": [ - "django_celery_beat", - "crontabschedule" - ], - "codename": "change_crontabschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete crontab", - "content_type": [ - "django_celery_beat", - "crontabschedule" - ], - "codename": "delete_crontabschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view crontab", - "content_type": [ - "django_celery_beat", - "crontabschedule" - ], - "codename": "view_crontabschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add interval", - "content_type": [ - "django_celery_beat", - "intervalschedule" - ], - "codename": "add_intervalschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change interval", - "content_type": [ - "django_celery_beat", - "intervalschedule" - ], - "codename": "change_intervalschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete interval", - "content_type": [ - "django_celery_beat", - "intervalschedule" - ], - "codename": "delete_intervalschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view interval", - "content_type": [ - "django_celery_beat", - "intervalschedule" - ], - "codename": "view_intervalschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add periodic task", - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "codename": "add_periodictask" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change periodic task", - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "codename": "change_periodictask" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete periodic task", - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "codename": "delete_periodictask" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view periodic task", - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "codename": "view_periodictask" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add periodic tasks", - "content_type": [ - "django_celery_beat", - "periodictasks" - ], - "codename": "add_periodictasks" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change periodic tasks", - "content_type": [ - "django_celery_beat", - "periodictasks" - ], - "codename": "change_periodictasks" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete periodic tasks", - "content_type": [ - "django_celery_beat", - "periodictasks" - ], - "codename": "delete_periodictasks" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view periodic tasks", - "content_type": [ - "django_celery_beat", - "periodictasks" - ], - "codename": "view_periodictasks" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add solar event", - "content_type": [ - "django_celery_beat", - "solarschedule" - ], - "codename": "add_solarschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change solar event", - "content_type": [ - "django_celery_beat", - "solarschedule" - ], - "codename": "change_solarschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete solar event", - "content_type": [ - "django_celery_beat", - "solarschedule" - ], - "codename": "delete_solarschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view solar event", - "content_type": [ - "django_celery_beat", - "solarschedule" - ], - "codename": "view_solarschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add clocked", - "content_type": [ - "django_celery_beat", - "clockedschedule" - ], - "codename": "add_clockedschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change clocked", - "content_type": [ - "django_celery_beat", - "clockedschedule" - ], - "codename": "change_clockedschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete clocked", - "content_type": [ - "django_celery_beat", - "clockedschedule" - ], - "codename": "delete_clockedschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view clocked", - "content_type": [ - "django_celery_beat", - "clockedschedule" - ], - "codename": "view_clockedschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add registration profile", - "content_type": [ - "registration", - "registrationprofile" - ], - "codename": "add_registrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change registration profile", - "content_type": [ - "registration", - "registrationprofile" - ], - "codename": "change_registrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete registration profile", - "content_type": [ - "registration", - "registrationprofile" - ], - "codename": "delete_registrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view registration profile", - "content_type": [ - "registration", - "registrationprofile" - ], - "codename": "view_registrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add supervised registration profile", - "content_type": [ - "registration", - "supervisedregistrationprofile" - ], - "codename": "add_supervisedregistrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change supervised registration profile", - "content_type": [ - "registration", - "supervisedregistrationprofile" - ], - "codename": "change_supervisedregistrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete supervised registration profile", - "content_type": [ - "registration", - "supervisedregistrationprofile" - ], - "codename": "delete_supervisedregistrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view supervised registration profile", - "content_type": [ - "registration", - "supervisedregistrationprofile" - ], - "codename": "view_supervisedregistrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add access attempt", - "content_type": [ - "axes", - "accessattempt" - ], - "codename": "add_accessattempt" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change access attempt", - "content_type": [ - "axes", - "accessattempt" - ], - "codename": "change_accessattempt" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete access attempt", - "content_type": [ - "axes", - "accessattempt" - ], - "codename": "delete_accessattempt" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view access attempt", - "content_type": [ - "axes", - "accessattempt" - ], - "codename": "view_accessattempt" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add access log", - "content_type": [ - "axes", - "accesslog" - ], - "codename": "add_accesslog" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change access log", - "content_type": [ - "axes", - "accesslog" - ], - "codename": "change_accesslog" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete access log", - "content_type": [ - "axes", - "accesslog" - ], - "codename": "delete_accesslog" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view access log", - "content_type": [ - "axes", - "accesslog" - ], - "codename": "view_accesslog" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add user", - "content_type": [ - "accounts", - "user" - ], - "codename": "add_user" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change user", - "content_type": [ - "accounts", - "user" - ], - "codename": "change_user" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete user", - "content_type": [ - "accounts", - "user" - ], - "codename": "delete_user" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view user", - "content_type": [ - "accounts", - "user" - ], - "codename": "view_user" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add post", - "content_type": [ - "core", - "post" - ], - "codename": "add_post" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change post", - "content_type": [ - "core", - "post" - ], - "codename": "change_post" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete post", - "content_type": [ - "core", - "post" - ], - "codename": "delete_post" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view post", - "content_type": [ - "core", - "post" - ], - "codename": "view_post" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add Category", - "content_type": [ - "core", - "category" - ], - "codename": "add_category" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change Category", - "content_type": [ - "core", - "category" - ], - "codename": "change_category" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete Category", - "content_type": [ - "core", - "category" - ], - "codename": "delete_category" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view Category", - "content_type": [ - "core", - "category" - ], - "codename": "view_category" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add collection rule", - "content_type": [ - "collection", - "collectionrule" - ], - "codename": "add_collectionrule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change collection rule", - "content_type": [ - "collection", - "collectionrule" - ], - "codename": "change_collectionrule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete collection rule", - "content_type": [ - "collection", - "collectionrule" - ], - "codename": "delete_collectionrule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view collection rule", - "content_type": [ - "collection", - "collectionrule" - ], - "codename": "view_collectionrule" - } -}, -{ - "model": "accounts.user", - "fields": { - "password": "pbkdf2_sha256$180000$KGKGsPnSwyiN$RqQAD46r4Kzqndqp5dmpj+H/drDrPRI0r6j4gLtYBjE=", - "last_login": "2020-05-24T19:45:49.721Z", - "is_superuser": true, - "first_name": "", - "last_name": "", - "is_staff": true, - "is_active": true, - "date_joined": "2019-07-18T18:52:36.080Z", - "email": "sonny@bakker.nl", - "task": 10, - "groups": [], - "user_permissions": [] - } -}, -{ - "model": "core.category", - "pk": 8, - "fields": { - "created": "2019-11-17T19:37:24.671Z", - "modified": "2019-11-18T19:59:55.010Z", - "name": "World news", - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "core.category", - "pk": 9, - "fields": { - "created": "2019-11-17T19:37:26.161Z", - "modified": "2019-11-18T19:59:45.010Z", - "name": "Tech", - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 3, - "fields": { - "created": "2019-07-14T13:08:10.374Z", - "modified": "2020-05-02T20:06:25.841Z", - "name": "Hackers News", - "url": "https://news.ycombinator.com/rss", - "website_url": "https://news.ycombinator.com/", - "favicon": "https://news.ycombinator.com/favicon.ico", - "timezone": "UTC", - "category": 9, - "last_suceeded": "2020-05-02T20:06:25.793Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 4, - "fields": { - "created": "2019-07-20T11:24:32.745Z", - "modified": "2020-05-02T20:06:24.719Z", - "name": "BBC", - "url": "http://feeds.bbci.co.uk/news/world/rss.xml", - "website_url": "https://www.bbc.co.uk/news/", - "favicon": "https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png", - "timezone": "UTC", - "category": 8, - "last_suceeded": "2020-05-02T20:06:24.128Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 5, - "fields": { - "created": "2019-07-20T11:24:50.411Z", - "modified": "2020-05-02T20:06:25.548Z", - "name": "Ars Technica", - "url": "http://feeds.arstechnica.com/arstechnica/index?fmt=xml", - "website_url": "https://arstechnica.com", - "favicon": "https://cdn.arstechnica.net/favicon.ico", - "timezone": "UTC", - "category": 9, - "last_suceeded": "2020-05-02T20:06:25.364Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 6, - "fields": { - "created": "2019-07-20T11:25:02.089Z", - "modified": "2020-05-02T20:06:25.741Z", - "name": "The Guardian", - "url": "https://www.theguardian.com/world/rss", - "website_url": "https://www.theguardian.com/world", - "favicon": "https://assets.guim.co.uk/images/favicons/873381bf11d58e20f551905d51575117/72x72.png", - "timezone": "UTC", - "category": 8, - "last_suceeded": "2020-05-02T20:06:25.620Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 7, - "fields": { - "created": "2019-07-20T11:25:30.121Z", - "modified": "2020-05-02T20:06:25.352Z", - "name": "Tweakers", - "url": "http://feeds.feedburner.com/tweakers/mixed?fmt=xml", - "website_url": "https://tweakers.net/", - "favicon": null, - "timezone": "UTC", - "category": 9, - "last_suceeded": "2020-05-02T20:06:24.730Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 8, - "fields": { - "created": "2019-07-20T11:25:46.256Z", - "modified": "2020-05-02T20:06:25.792Z", - "name": "The Verge", - "url": "https://www.theverge.com/rss/index.xml", - "website_url": "https://www.theverge.com/", - "favicon": "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395367/favicon-16x16.0.png", - "timezone": "UTC", - "category": 9, - "last_suceeded": "2020-05-02T20:06:25.742Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 9, - "fields": { - "created": "2019-11-24T15:28:41.399Z", - "modified": "2020-05-02T20:06:25.619Z", - "name": "NOS", - "url": "http://feeds.nos.nl/nosnieuwsalgemeen", - "website_url": null, - "favicon": null, - "timezone": "Europe/Amsterdam", - "category": 8, - "last_suceeded": "2020-05-02T20:06:25.549Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 10, - "fields": { - "created": "2020-05-02T20:32:34.107Z", - "modified": "2020-05-02T20:32:34.107Z", - "name": "CollectionRule-0", - "url": "http://rasmussen-guerra.com/", - "website_url": "https://ritter.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 11, - "fields": { - "created": "2020-05-02T20:32:34.164Z", - "modified": "2020-05-02T20:32:34.164Z", - "name": "CollectionRule-1", - "url": "https://www.evans.com/", - "website_url": "https://taylor.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 12, - "fields": { - "created": "2020-05-02T20:32:34.220Z", - "modified": "2020-05-02T20:32:34.220Z", - "name": "CollectionRule-2", - "url": "http://weaver-quinn.net/", - "website_url": "https://www.mcintyre.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 13, - "fields": { - "created": "2020-05-02T20:32:34.277Z", - "modified": "2020-05-02T20:32:34.277Z", - "name": "CollectionRule-3", - "url": "http://www.palmer.com/", - "website_url": "http://www.riggs.org/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 14, - "fields": { - "created": "2020-05-02T20:32:34.333Z", - "modified": "2020-05-02T20:32:34.333Z", - "name": "CollectionRule-4", - "url": "http://moody-stein.net/", - "website_url": "https://www.lewis.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 15, - "fields": { - "created": "2020-05-02T20:32:34.390Z", - "modified": "2020-05-02T20:32:34.391Z", - "name": "CollectionRule-5", - "url": "http://www.ochoa.com/", - "website_url": "https://brown.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 16, - "fields": { - "created": "2020-05-02T20:32:34.448Z", - "modified": "2020-05-02T20:32:34.448Z", - "name": "CollectionRule-6", - "url": "https://www.pearson.biz/", - "website_url": "http://acosta-johnson.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 17, - "fields": { - "created": "2020-05-02T20:32:34.506Z", - "modified": "2020-05-02T20:32:34.506Z", - "name": "CollectionRule-7", - "url": "https://jones.com/", - "website_url": "https://www.thornton.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 18, - "fields": { - "created": "2020-05-02T20:32:34.562Z", - "modified": "2020-05-02T20:32:34.562Z", - "name": "CollectionRule-8", - "url": "http://www.matthews-graves.com/", - "website_url": "http://stewart.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 19, - "fields": { - "created": "2020-05-02T20:32:34.618Z", - "modified": "2020-05-02T20:32:34.618Z", - "name": "CollectionRule-9", - "url": "http://www.kelly-martinez.com/", - "website_url": "https://www.freeman.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 20, - "fields": { - "created": "2020-05-02T20:32:34.674Z", - "modified": "2020-05-02T20:32:34.674Z", - "name": "CollectionRule-10", - "url": "https://www.roberts.biz/", - "website_url": "http://www.lopez.info/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 21, - "fields": { - "created": "2020-05-02T20:32:34.730Z", - "modified": "2020-05-02T20:32:34.730Z", - "name": "CollectionRule-11", - "url": "https://www.holmes-cross.com/", - "website_url": "https://www.ramirez.net/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 22, - "fields": { - "created": "2020-05-02T20:32:34.786Z", - "modified": "2020-05-02T20:32:34.786Z", - "name": "CollectionRule-12", - "url": "https://www.jenkins.com/", - "website_url": "https://www.faulkner.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 23, - "fields": { - "created": "2020-05-02T20:32:34.841Z", - "modified": "2020-05-02T20:32:34.842Z", - "name": "CollectionRule-13", - "url": "https://www.adkins.com/", - "website_url": "https://www.munoz-brown.info/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 24, - "fields": { - "created": "2020-05-02T20:32:34.897Z", - "modified": "2020-05-02T20:32:34.898Z", - "name": "CollectionRule-14", - "url": "https://www.rodriguez-ortega.biz/", - "website_url": "http://www.santos.info/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 25, - "fields": { - "created": "2020-05-02T20:32:34.953Z", - "modified": "2020-05-02T20:32:34.954Z", - "name": "CollectionRule-15", - "url": "https://www.hawkins-stewart.com/", - "website_url": "http://www.jones.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 26, - "fields": { - "created": "2020-05-02T20:32:35.010Z", - "modified": "2020-05-02T20:32:35.010Z", - "name": "CollectionRule-16", - "url": "http://mullins.net/", - "website_url": "https://www.curtis.org/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 27, - "fields": { - "created": "2020-05-02T20:32:35.067Z", - "modified": "2020-05-02T20:32:35.067Z", - "name": "CollectionRule-17", - "url": "http://frederick.com/", - "website_url": "https://www.fowler.info/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 28, - "fields": { - "created": "2020-05-02T20:32:35.124Z", - "modified": "2020-05-02T20:32:35.124Z", - "name": "CollectionRule-18", - "url": "http://schmidt.com/", - "website_url": "http://bryant-hoffman.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 29, - "fields": { - "created": "2020-05-02T20:32:35.180Z", - "modified": "2020-05-02T20:32:35.180Z", - "name": "CollectionRule-19", - "url": "https://www.jones.net/", - "website_url": "http://benjamin.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 30, - "fields": { - "created": "2020-05-02T20:32:35.237Z", - "modified": "2020-05-02T20:32:35.237Z", - "name": "CollectionRule-20", - "url": "https://www.parker-lewis.com/", - "website_url": "http://www.anderson.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 31, - "fields": { - "created": "2020-05-02T20:32:35.294Z", - "modified": "2020-05-02T20:32:35.294Z", - "name": "CollectionRule-21", - "url": "http://martinez.com/", - "website_url": "http://burton-scott.biz/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 32, - "fields": { - "created": "2020-05-02T20:32:35.350Z", - "modified": "2020-05-02T20:32:35.350Z", - "name": "CollectionRule-22", - "url": "https://gibbs.com/", - "website_url": "https://www.robertson.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 33, - "fields": { - "created": "2020-05-02T20:32:35.407Z", - "modified": "2020-05-02T20:32:35.407Z", - "name": "CollectionRule-23", - "url": "http://www.fisher.com/", - "website_url": "https://mcclure-miller.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 34, - "fields": { - "created": "2020-05-02T20:32:35.463Z", - "modified": "2020-05-02T20:32:35.463Z", - "name": "CollectionRule-24", - "url": "https://schneider-lopez.org/", - "website_url": "https://andrews-williams.biz/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 35, - "fields": { - "created": "2020-05-02T20:32:35.522Z", - "modified": "2020-05-02T20:32:35.522Z", - "name": "CollectionRule-25", - "url": "http://www.rogers.info/", - "website_url": "https://www.petersen-stewart.biz/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 36, - "fields": { - "created": "2020-05-02T20:32:35.581Z", - "modified": "2020-05-02T20:32:35.581Z", - "name": "CollectionRule-26", - "url": "http://torres.com/", - "website_url": "https://hart-tapia.org/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 37, - "fields": { - "created": "2020-05-02T20:32:35.637Z", - "modified": "2020-05-02T20:32:35.638Z", - "name": "CollectionRule-27", - "url": "http://www.pham-scott.com/", - "website_url": "http://smith-diaz.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 38, - "fields": { - "created": "2020-05-02T20:32:35.699Z", - "modified": "2020-05-02T20:32:35.699Z", - "name": "CollectionRule-28", - "url": "http://www.gonzalez-castillo.com/", - "website_url": "http://www.conley.biz/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 39, - "fields": { - "created": "2020-05-02T20:32:35.758Z", - "modified": "2020-05-02T20:32:35.758Z", - "name": "CollectionRule-29", - "url": "https://rogers-smith.net/", - "website_url": "http://www.sharp.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 40, - "fields": { - "created": "2020-05-02T20:32:35.814Z", - "modified": "2020-05-02T20:32:35.814Z", - "name": "CollectionRule-30", - "url": "https://neal-salinas.com/", - "website_url": "https://www.baird-warner.net/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 41, - "fields": { - "created": "2020-05-02T20:32:35.873Z", - "modified": "2020-05-02T20:32:35.874Z", - "name": "CollectionRule-31", - "url": "http://www.williams.com/", - "website_url": "http://www.wood.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 42, - "fields": { - "created": "2020-05-02T20:32:35.930Z", - "modified": "2020-05-02T20:32:35.930Z", - "name": "CollectionRule-32", - "url": "https://www.mueller.com/", - "website_url": "http://www.miller-ramirez.org/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 43, - "fields": { - "created": "2020-05-02T20:32:35.988Z", - "modified": "2020-05-02T20:32:35.989Z", - "name": "CollectionRule-33", - "url": "http://lee.com/", - "website_url": "http://www.moody.org/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 44, - "fields": { - "created": "2020-05-02T20:32:36.044Z", - "modified": "2020-05-02T20:32:36.045Z", - "name": "CollectionRule-34", - "url": "http://estrada.com/", - "website_url": "http://www.hicks.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 45, - "fields": { - "created": "2020-05-02T20:32:36.102Z", - "modified": "2020-05-02T20:32:36.102Z", - "name": "CollectionRule-35", - "url": "https://griffin-brewer.org/", - "website_url": "http://jones.info/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 46, - "fields": { - "created": "2020-05-02T20:32:36.161Z", - "modified": "2020-05-02T20:32:36.161Z", - "name": "CollectionRule-36", - "url": "http://www.dixon-johnson.com/", - "website_url": "https://mason.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 47, - "fields": { - "created": "2020-05-02T20:32:36.217Z", - "modified": "2020-05-02T20:32:36.217Z", - "name": "CollectionRule-37", - "url": "https://perez.com/", - "website_url": "http://www.miller.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 48, - "fields": { - "created": "2020-05-02T20:32:36.278Z", - "modified": "2020-05-02T20:32:36.279Z", - "name": "CollectionRule-38", - "url": "https://www.grant.net/", - "website_url": "https://www.clayton.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 49, - "fields": { - "created": "2020-05-02T20:32:36.336Z", - "modified": "2020-05-02T20:32:36.336Z", - "name": "CollectionRule-39", - "url": "http://www.lewis.org/", - "website_url": "http://cook.org/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 50, - "fields": { - "created": "2020-05-02T20:32:36.395Z", - "modified": "2020-05-02T20:32:36.395Z", - "name": "CollectionRule-40", - "url": "https://galloway-allen.net/", - "website_url": "http://www.rodriguez-callahan.info/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 51, - "fields": { - "created": "2020-05-02T20:32:36.453Z", - "modified": "2020-05-02T20:32:36.453Z", - "name": "CollectionRule-41", - "url": "https://www.macias.com/", - "website_url": "https://jarvis-green.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 52, - "fields": { - "created": "2020-05-02T20:32:36.510Z", - "modified": "2020-05-02T20:32:36.510Z", - "name": "CollectionRule-42", - "url": "http://mccullough-grant.com/", - "website_url": "https://shannon.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 53, - "fields": { - "created": "2020-05-02T20:32:36.566Z", - "modified": "2020-05-02T20:32:36.566Z", - "name": "CollectionRule-43", - "url": "http://www.foster-oneal.org/", - "website_url": "http://johns.org/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 54, - "fields": { - "created": "2020-05-02T20:32:36.623Z", - "modified": "2020-05-02T20:32:36.623Z", - "name": "CollectionRule-44", - "url": "http://www.wright.net/", - "website_url": "http://www.ali.biz/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 55, - "fields": { - "created": "2020-05-02T20:32:36.682Z", - "modified": "2020-05-02T20:32:36.682Z", - "name": "CollectionRule-45", - "url": "http://www.payne-gibbs.info/", - "website_url": "http://knight.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 56, - "fields": { - "created": "2020-05-02T20:32:36.740Z", - "modified": "2020-05-02T20:32:36.740Z", - "name": "CollectionRule-46", - "url": "http://hammond.biz/", - "website_url": "http://www.nelson.net/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 57, - "fields": { - "created": "2020-05-02T20:32:36.797Z", - "modified": "2020-05-02T20:32:36.797Z", - "name": "CollectionRule-47", - "url": "http://gilmore.com/", - "website_url": "http://coleman.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 58, - "fields": { - "created": "2020-05-02T20:32:36.855Z", - "modified": "2020-05-02T20:32:36.855Z", - "name": "CollectionRule-48", - "url": "https://www.hernandez.com/", - "website_url": "https://www.phillips.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 59, - "fields": { - "created": "2020-05-02T20:32:36.912Z", - "modified": "2020-05-02T20:32:36.912Z", - "name": "CollectionRule-49", - "url": "https://www.nguyen.com/", - "website_url": "http://www.floyd.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 60, - "fields": { - "created": "2020-05-02T20:32:36.969Z", - "modified": "2020-05-02T20:32:36.969Z", - "name": "CollectionRule-50", - "url": "https://meyer-brown.net/", - "website_url": "https://www.blankenship.biz/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 61, - "fields": { - "created": "2020-05-02T20:32:37.026Z", - "modified": "2020-05-02T20:32:37.027Z", - "name": "CollectionRule-51", - "url": "https://marks.net/", - "website_url": "http://gregory.net/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 62, - "fields": { - "created": "2020-05-02T20:32:37.087Z", - "modified": "2020-05-02T20:32:37.087Z", - "name": "CollectionRule-52", - "url": "http://www.baxter.com/", - "website_url": "http://barrera.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 63, - "fields": { - "created": "2020-05-02T20:32:37.143Z", - "modified": "2020-05-02T20:32:37.143Z", - "name": "CollectionRule-53", - "url": "http://johnson.com/", - "website_url": "https://abbott.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 64, - "fields": { - "created": "2020-05-02T20:32:37.202Z", - "modified": "2020-05-02T20:32:37.202Z", - "name": "CollectionRule-54", - "url": "https://hebert-marshall.biz/", - "website_url": "https://www.ashley-walsh.org/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 65, - "fields": { - "created": "2020-05-02T20:32:37.261Z", - "modified": "2020-05-02T20:32:37.261Z", - "name": "CollectionRule-55", - "url": "https://miller.com/", - "website_url": "https://www.hoffman.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 66, - "fields": { - "created": "2020-05-02T20:32:37.320Z", - "modified": "2020-05-02T20:32:37.320Z", - "name": "CollectionRule-56", - "url": "http://frey.com/", - "website_url": "https://long.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 67, - "fields": { - "created": "2020-05-02T20:32:37.379Z", - "modified": "2020-05-02T20:32:37.379Z", - "name": "CollectionRule-57", - "url": "https://edwards.com/", - "website_url": "http://www.nixon-doyle.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 68, - "fields": { - "created": "2020-05-02T20:32:37.435Z", - "modified": "2020-05-02T20:32:37.435Z", - "name": "CollectionRule-58", - "url": "https://www.bennett.com/", - "website_url": "http://sullivan.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 69, - "fields": { - "created": "2020-05-02T20:32:37.493Z", - "modified": "2020-05-02T20:32:37.493Z", - "name": "CollectionRule-59", - "url": "http://stokes-thomas.com/", - "website_url": "http://morgan.net/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 70, - "fields": { - "created": "2020-05-02T20:32:37.550Z", - "modified": "2020-05-02T20:32:37.550Z", - "name": "CollectionRule-60", - "url": "https://moore.net/", - "website_url": "http://www.hubbard.biz/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 71, - "fields": { - "created": "2020-05-02T20:32:37.609Z", - "modified": "2020-05-02T20:32:37.609Z", - "name": "CollectionRule-61", - "url": "https://baker-edwards.com/", - "website_url": "https://www.anderson.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 72, - "fields": { - "created": "2020-05-02T20:32:37.666Z", - "modified": "2020-05-02T20:32:37.666Z", - "name": "CollectionRule-62", - "url": "https://www.jackson.com/", - "website_url": "https://www.edwards.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 73, - "fields": { - "created": "2020-05-02T20:32:37.724Z", - "modified": "2020-05-02T20:32:37.724Z", - "name": "CollectionRule-63", - "url": "https://kemp-pollard.biz/", - "website_url": "http://www.fuentes.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 74, - "fields": { - "created": "2020-05-02T20:32:37.782Z", - "modified": "2020-05-02T20:32:37.782Z", - "name": "CollectionRule-64", - "url": "https://hanna-cook.com/", - "website_url": "http://www.bowen.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 75, - "fields": { - "created": "2020-05-02T20:32:37.839Z", - "modified": "2020-05-02T20:32:37.839Z", - "name": "CollectionRule-65", - "url": "http://www.williams.net/", - "website_url": "http://www.chandler.org/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 76, - "fields": { - "created": "2020-05-02T20:32:37.896Z", - "modified": "2020-05-02T20:32:37.896Z", - "name": "CollectionRule-66", - "url": "https://www.alexander.com/", - "website_url": "https://johnson-ellis.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 77, - "fields": { - "created": "2020-05-02T20:32:37.951Z", - "modified": "2020-05-02T20:32:37.951Z", - "name": "CollectionRule-67", - "url": "https://www.cisneros.com/", - "website_url": "http://fox.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 78, - "fields": { - "created": "2020-05-02T20:32:38.008Z", - "modified": "2020-05-02T20:32:38.008Z", - "name": "CollectionRule-68", - "url": "http://www.foster-burton.com/", - "website_url": "https://grant.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 79, - "fields": { - "created": "2020-05-02T20:32:38.066Z", - "modified": "2020-05-02T20:32:38.066Z", - "name": "CollectionRule-69", - "url": "https://www.hayes.net/", - "website_url": "http://morgan.com/", - "favicon": null, - "timezone": "UTC", - "category": null, - "last_suceeded": null, - "succeeded": false, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "admin.logentry", - "pk": 1, - "fields": { - "action_time": "2020-05-24T18:38:44.624Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "django_celery_beat", - "intervalschedule" - ], - "object_id": "5", - "object_repr": "every 4 hours", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 2, - "fields": { - "action_time": "2020-05-24T18:38:46.689Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "object_id": "10", - "object_repr": "sonny@bakker.nl-collection-task: every 4 hours", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Interval Schedule\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 3, - "fields": { - "action_time": "2020-05-24T18:39:09.203Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "object_id": "26", - "object_repr": "sonnyba871@gmail.com-collection-task: every hour", - "action_flag": 3, - "change_message": "" - } -}, -{ - "model": "admin.logentry", - "pk": 4, - "fields": { - "action_time": "2020-05-24T19:46:50.248Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "object_id": "10", - "object_repr": "sonny@bakker.nl-collection-task: every 4 hours", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Positional Arguments\"]}}]" - } -} -] +[ +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "admin", + "model": "logentry" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "auth", + "model": "permission" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "auth", + "model": "group" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "contenttypes", + "model": "contenttype" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "sessions", + "model": "session" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "crontabschedule" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "intervalschedule" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "periodictask" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "periodictasks" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "solarschedule" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "clockedschedule" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "registration", + "model": "registrationprofile" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "registration", + "model": "supervisedregistrationprofile" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "axes", + "model": "accessattempt" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "axes", + "model": "accesslog" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "accounts", + "model": "user" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "core", + "model": "post" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "core", + "model": "category" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "collection", + "model": "collectionrule" + } +}, +{ + "model": "sessions.session", + "pk": "3sumq22krk8tsvexcs4b8czu82yhvuer", + "fields": { + "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", + "expire_date": "2020-05-16T18:29:04.049Z" + } +}, +{ + "model": "sessions.session", + "pk": "8ix6bdwf2ywk0eir1hb062dhfh9xit85", + "fields": { + "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", + "expire_date": "2020-07-21T19:36:54.530Z" + } +}, +{ + "model": "sessions.session", + "pk": "d4wophwpjm8z96doe8iddvhdv9yfafyx", + "fields": { + "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", + "expire_date": "2020-06-07T19:45:49.727Z" + } +}, +{ + "model": "sessions.session", + "pk": "g23ziz66li5zx8nd8cewb3vevdxhjkm0", + "fields": { + "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", + "expire_date": "2020-06-30T06:55:50.747Z" + } +}, +{ + "model": "sessions.session", + "pk": "jwn66dptmdkm6hom2ns3j288aaxqtyjd", + "fields": { + "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", + "expire_date": "2020-06-07T18:38:19.116Z" + } +}, +{ + "model": "sessions.session", + "pk": "wjz6kwg5e5ciemre0l0wwyrcwcj2gyg6", + "fields": { + "session_data": "MWU5ODBjY2QyOTFhMmRiY2QyYjQwZjQ3MmMwYmExYjBlYTkxNTcwODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiI0YWZkYTkxNzU5ZDBhZDZmMjg1ZTQyOGY0OTUxN2M5MTJhMmM5NWIyIn0=", + "expire_date": "2020-08-09T09:52:04.705Z" + } +}, +{ + "model": "django_celery_beat.intervalschedule", + "pk": 1, + "fields": { + "every": 5, + "period": "minutes" + } +}, +{ + "model": "django_celery_beat.intervalschedule", + "pk": 2, + "fields": { + "every": 15, + "period": "minutes" + } +}, +{ + "model": "django_celery_beat.intervalschedule", + "pk": 3, + "fields": { + "every": 30, + "period": "minutes" + } +}, +{ + "model": "django_celery_beat.intervalschedule", + "pk": 4, + "fields": { + "every": 1, + "period": "hours" + } +}, +{ + "model": "django_celery_beat.intervalschedule", + "pk": 5, + "fields": { + "every": 4, + "period": "hours" + } +}, +{ + "model": "django_celery_beat.crontabschedule", + "pk": 1, + "fields": { + "minute": "0", + "hour": "4", + "day_of_week": "*", + "day_of_month": "*", + "month_of_year": "*", + "timezone": "UTC" + } +}, +{ + "model": "django_celery_beat.periodictasks", + "pk": 1, + "fields": { + "last_update": "2020-07-26T09:47:48.298Z" + } +}, +{ + "model": "django_celery_beat.periodictask", + "pk": 1, + "fields": { + "name": "celery.backend_cleanup", + "task": "celery.backend_cleanup", + "interval": null, + "crontab": 1, + "solar": null, + "clocked": null, + "args": "[]", + "kwargs": "{}", + "queue": null, + "exchange": null, + "routing_key": null, + "headers": "{}", + "priority": null, + "expires": null, + "expire_seconds": 43200, + "one_off": false, + "start_time": null, + "enabled": true, + "last_run_at": "2020-07-26T09:47:48.322Z", + "total_run_count": 17, + "date_changed": "2020-07-26T09:47:50.362Z", + "description": "" + } +}, +{ + "model": "django_celery_beat.periodictask", + "pk": 10, + "fields": { + "name": "sonny@bakker.nl-collection-task", + "task": "FeedTask", + "interval": 5, + "crontab": null, + "solar": null, + "clocked": null, + "args": "[1]", + "kwargs": "{}", + "queue": null, + "exchange": null, + "routing_key": null, + "headers": "{}", + "priority": null, + "expires": null, + "expire_seconds": null, + "one_off": false, + "start_time": null, + "enabled": false, + "last_run_at": "2020-07-14T11:45:26.209Z", + "total_run_count": 307, + "date_changed": "2020-07-14T11:45:41.282Z", + "description": "" + } +}, +{ + "model": "django_celery_beat.periodictask", + "pk": 11, + "fields": { + "name": "Reddit collection task", + "task": "RedditTask", + "interval": 5, + "crontab": null, + "solar": null, + "clocked": null, + "args": "[]", + "kwargs": "{}", + "queue": null, + "exchange": null, + "routing_key": null, + "headers": "{}", + "priority": null, + "expires": null, + "expire_seconds": null, + "one_off": false, + "start_time": null, + "enabled": false, + "last_run_at": null, + "total_run_count": 4, + "date_changed": "2020-07-14T11:45:41.316Z", + "description": "" + } +}, +{ + "model": "core.post", + "pk": 3061, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:14:50.423Z", + "title": "Star Citizen: Question and Answer Thread", + "body": "

      Welcome to the Star Citizen question and answer thread. Feel free to ask any questions you have related to SC here!

      \n\n\n\n

      Useful Links and Resources:

      \n\n

      Star Citizen Wiki - The biggest and best wiki resource dedicated to Star Citizen

      \n\n

      Star Citizen FAQ - Chances the answer you need is here.

      \n\n

      Discord Help Channel - Often times community members will be here to help you with issues.

      \n\n

      Referral Code Randomizer - Use this when creating a new account to get 5000 extra UEC.

      \n\n

      Download Star Citizen - Get the latest version of Star Citizen here

      \n\n

      Current Game Features - Click here to see what you can currently do in Star Citizen.

      \n\n

      Development Roadmap - The current development status of up and coming Star Citizen features.

      \n\n

      Pledge FAQ - Official FAQ regarding spending money on the game.

      \n
      ", + "author": "UEE_Central_Computer", + "publication_date": "2020-07-20T14:00:10Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huk04t/star_citizen_question_and_answer_thread/", + "read": false, + "rule": 82, + "remote_identifier": "huk04t" + } +}, +{ + "model": "core.post", + "pk": 3062, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:33:37.019Z", + "title": "Peace and Quiet", + "body": "
      \"Peace
      ", + "author": "SourMemeNZ", + "publication_date": "2020-07-20T14:09:49Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huk4ib/peace_and_quiet/", + "read": true, + "rule": 82, + "remote_identifier": "huk4ib" + } +}, +{ + "model": "core.post", + "pk": 3063, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:14:50.463Z", + "title": "Y'all are probably sick of em by now but here's my LEGO Mercury Star Runner (MSR).", + "body": "
      \"Y'all
      ", + "author": "osamadabinman", + "publication_date": "2020-07-20T19:53:23Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hupzqa/yall_are_probably_sick_of_em_by_now_but_heres_my/", + "read": true, + "rule": 82, + "remote_identifier": "hupzqa" + } +}, +{ + "model": "core.post", + "pk": 3064, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:17:12.253Z", + "title": "Damned Space Invaders and their pixel weapons!", + "body": "
      \"Damned
      ", + "author": "Akaradrin", + "publication_date": "2020-07-20T14:26:18Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hukckf/damned_space_invaders_and_their_pixel_weapons/", + "read": true, + "rule": 82, + "remote_identifier": "hukckf" + } +}, +{ + "model": "core.post", + "pk": 3065, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:32:35.578Z", + "title": "The sky is no longer the limit", + "body": "
      \"The
      ", + "author": "CyberTill", + "publication_date": "2020-07-20T14:11:31Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huk5b8/the_sky_is_no_longer_the_limit/", + "read": false, + "rule": 82, + "remote_identifier": "huk5b8" + } +}, +{ + "model": "core.post", + "pk": 3066, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:17:23.282Z", + "title": "Terrapin Hover Mode Gameplay [Full Video in Comments]", + "body": "
      ", + "author": "Didactic_Tomato", + "publication_date": "2020-07-20T11:01:13Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hui1gv/terrapin_hover_mode_gameplay_full_video_in/", + "read": true, + "rule": 82, + "remote_identifier": "hui1gv" + } +}, +{ + "model": "core.post", + "pk": 3067, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:17:44.250Z", + "title": "honestly", + "body": "
      \"honestly\"
      ", + "author": "Beatlead", + "publication_date": "2020-07-20T18:24:07Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huo96t/honestly/", + "read": true, + "rule": 82, + "remote_identifier": "huo96t" + } +}, +{ + "model": "core.post", + "pk": 3068, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:32:35.584Z", + "title": "As a paranoiac and tired of checking if door was closed, saved to f4 thoses \"security cam\" positions, could be usefull for larger ships :)", + "body": "", + "author": "icwiener__", + "publication_date": "2020-07-20T13:03:33Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hujchz/as_a_paranoiac_and_tired_of_checking_if_door_was/", + "read": false, + "rule": 82, + "remote_identifier": "hujchz" + } +}, +{ + "model": "core.post", + "pk": 3069, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:33:59.158Z", + "title": "Station Manager: \"You're too fat, we won't let you in, go and fall on Lorville. Thank you for your call!\" Me: \"okay :'(\"", + "body": "
      \"Station
      ", + "author": "Shaman_N_One", + "publication_date": "2020-07-20T11:33:38Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huidlu/station_manager_youre_too_fat_we_wont_let_you_in/", + "read": true, + "rule": 82, + "remote_identifier": "huidlu" + } +}, +{ + "model": "core.post", + "pk": 3070, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:32:35.588Z", + "title": "[PTU Bug Hunt Request] Packet Loss", + "body": "", + "author": "Rainwalker007", + "publication_date": "2020-07-20T18:38:03Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huoicq/ptu_bug_hunt_request_packet_loss/", + "read": false, + "rule": 82, + "remote_identifier": "huoicq" + } +}, +{ + "model": "core.post", + "pk": 3071, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:17:52.092Z", + "title": "Anyone able to explain these \"trail frames\"?", + "body": "
      \"Anyone
      ", + "author": "Abnormal_Sloth", + "publication_date": "2020-07-20T17:11:32Z", + "url": "https://www.reddit.com/r/starcitizen/comments/humyeq/anyone_able_to_explain_these_trail_frames/", + "read": true, + "rule": 82, + "remote_identifier": "humyeq" + } +}, +{ + "model": "core.post", + "pk": 3072, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:32:35.593Z", + "title": "#BringBackBugSmasher - A long forgotten legendary video content", + "body": "", + "author": "MasterBoring", + "publication_date": "2020-07-20T18:05:54Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hunx77/bringbackbugsmasher_a_long_forgotten_legendary/", + "read": false, + "rule": 82, + "remote_identifier": "hunx77" + } +}, +{ + "model": "core.post", + "pk": 3073, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:33:22.601Z", + "title": "Oracle Helmet [in-game screenshot; downsampled to 4k]", + "body": "
      \"Oracle
      ", + "author": "mr-hasgaha", + "publication_date": "2020-07-20T17:39:34Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hung0b/oracle_helmet_ingame_screenshot_downsampled_to_4k/", + "read": true, + "rule": 82, + "remote_identifier": "hung0b" + } +}, +{ + "model": "core.post", + "pk": 3074, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:34:42.578Z", + "title": "Testing 3.10 - Gladius in decoupled mode", + "body": "
      ", + "author": "DarkConstant", + "publication_date": "2020-07-19T21:26:52Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hu6f1h/testing_310_gladius_in_decoupled_mode/", + "read": true, + "rule": 82, + "remote_identifier": "hu6f1h" + } +}, +{ + "model": "core.post", + "pk": 3075, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:34:29.424Z", + "title": "Day 3, I can't stop taking pictures with my Carrack. Send help", + "body": "
      \"Day
      ", + "author": "CyberTill", + "publication_date": "2020-07-20T01:58:15Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huazyy/day_3_i_cant_stop_taking_pictures_with_my_carrack/", + "read": true, + "rule": 82, + "remote_identifier": "huazyy" + } +}, +{ + "model": "core.post", + "pk": 3076, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:32:35.602Z", + "title": "I used to enjoy flying between the buildings of new babbage, I mean before the NFZ \"improvement\"", + "body": "
      \"I
      ", + "author": "shoeii", + "publication_date": "2020-07-20T16:40:26Z", + "url": "https://www.reddit.com/r/starcitizen/comments/humet2/i_used_to_enjoy_flying_between_the_buildings_of/", + "read": false, + "rule": 82, + "remote_identifier": "humet2" + } +}, +{ + "model": "core.post", + "pk": 3077, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:18:04.237Z", + "title": "Thank you CIG for updated heightmaps and render distances", + "body": "
      \"Thank
      ", + "author": "u7f76", + "publication_date": "2020-07-19T23:38:22Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hu8pwf/thank_you_cig_for_updated_heightmaps_and_render/", + "read": true, + "rule": 82, + "remote_identifier": "hu8pwf" + } +}, +{ + "model": "core.post", + "pk": 3078, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:32:35.607Z", + "title": "This Week in Star Citizen | July 20th 2020", + "body": "", + "author": "ivtiprogamer", + "publication_date": "2020-07-20T19:50:29Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hupxnt/this_week_in_star_citizen_july_20th_2020/", + "read": false, + "rule": 82, + "remote_identifier": "hupxnt" + } +}, +{ + "model": "core.post", + "pk": 3079, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:34:36.068Z", + "title": "Bravo CIG lighting team! Noticeable improvements to all around environment lighting in 3.10", + "body": "
      \"Bravo
      ", + "author": "u7f76", + "publication_date": "2020-07-20T00:02:23Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hu94o0/bravo_cig_lighting_team_noticeable_improvements/", + "read": true, + "rule": 82, + "remote_identifier": "hu94o0" + } +}, +{ + "model": "core.post", + "pk": 3080, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:32:35.613Z", + "title": "Thick", + "body": "
      \"Thick\"
      ", + "author": "burgerbagel", + "publication_date": "2020-07-20T16:24:38Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hum50f/thick/", + "read": false, + "rule": 82, + "remote_identifier": "hum50f" + } +}, +{ + "model": "core.post", + "pk": 3081, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:34:19.763Z", + "title": "Soon\u2122", + "body": "
      \"Soon\u2122\"
      ", + "author": "Mistralette", + "publication_date": "2020-07-20T05:54:09Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hueg01/soon/", + "read": true, + "rule": 82, + "remote_identifier": "hueg01" + } +}, +{ + "model": "core.post", + "pk": 3082, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:32:35.618Z", + "title": "On the prowl", + "body": "
      \"On
      ", + "author": "SaraCaterina", + "publication_date": "2020-07-20T16:37:03Z", + "url": "https://www.reddit.com/r/starcitizen/comments/humcmb/on_the_prowl/", + "read": false, + "rule": 82, + "remote_identifier": "humcmb" + } +}, +{ + "model": "core.post", + "pk": 3083, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:34:07.272Z", + "title": "The Hills Have Eyes", + "body": "
      \"The
      ", + "author": "FallenLordik", + "publication_date": "2020-07-20T11:19:19Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hui8ao/the_hills_have_eyes/", + "read": true, + "rule": 82, + "remote_identifier": "hui8ao" + } +}, +{ + "model": "core.post", + "pk": 3084, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:32:35.623Z", + "title": "Worried about longer loading screens? Hit ~ and do r_displayinfo 3", + "body": "
      \"Worried
      ", + "author": "kristokn", + "publication_date": "2020-07-20T10:09:53Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huhif1/worried_about_longer_loading_screens_hit_and_do_r/", + "read": false, + "rule": 82, + "remote_identifier": "huhif1" + } +}, +{ + "model": "core.post", + "pk": 3085, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:32:35.625Z", + "title": "My contribution to the wallpaper contest... click for the full effect (3440x1440)", + "body": "
      \"My
      ", + "author": "Dougie_Juice", + "publication_date": "2020-07-20T20:02:31Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huq655/my_contribution_to_the_wallpaper_contest_click/", + "read": false, + "rule": 82, + "remote_identifier": "huq655" + } +}, +{ + "model": "core.post", + "pk": 3086, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:32:35.627Z", + "title": "Star Citizen: The Onion (Parody Project)", + "body": "", + "author": "BroadOne", + "publication_date": "2020-07-20T19:19:20Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hupbkj/star_citizen_the_onion_parody_project/", + "read": false, + "rule": 82, + "remote_identifier": "hupbkj" + } +}, +{ + "model": "core.post", + "pk": 3087, + "fields": { + "created": "2020-07-20T19:32:35.635Z", + "modified": "2020-07-20T19:32:35.637Z", + "title": "perfect day to sunbathe", + "body": "
      ", + "author": "Pedrica1", + "publication_date": "2020-07-20T18:08:17Z", + "url": "https://www.reddit.com/r/aww/comments/hunysb/perfect_day_to_sunbathe/", + "read": false, + "rule": 81, + "remote_identifier": "hunysb" + } +}, +{ + "model": "core.post", + "pk": 3088, + "fields": { + "created": "2020-07-20T19:32:35.635Z", + "modified": "2020-07-20T19:32:35.639Z", + "title": "My dogs face when he sees I'm home", + "body": "
      ", + "author": "NewReddit_WhoDis", + "publication_date": "2020-07-20T16:45:21Z", + "url": "https://www.reddit.com/r/aww/comments/humhxa/my_dogs_face_when_he_sees_im_home/", + "read": false, + "rule": 81, + "remote_identifier": "humhxa" + } +}, +{ + "model": "core.post", + "pk": 3089, + "fields": { + "created": "2020-07-20T19:32:35.635Z", + "modified": "2020-07-20T19:32:35.641Z", + "title": "Cow loves the scritch machine", + "body": "
      ", + "author": "Der_Ist", + "publication_date": "2020-07-20T17:36:16Z", + "url": "https://www.reddit.com/r/aww/comments/hundvo/cow_loves_the_scritch_machine/", + "read": false, + "rule": 81, + "remote_identifier": "hundvo" + } +}, +{ + "model": "core.post", + "pk": 3090, + "fields": { + "created": "2020-07-20T19:32:35.635Z", + "modified": "2020-07-20T19:32:35.643Z", + "title": "Can I sit next to you ?", + "body": "
      ", + "author": "wheezy098", + "publication_date": "2020-07-20T17:55:10Z", + "url": "https://www.reddit.com/r/aww/comments/hunq5h/can_i_sit_next_to_you/", + "read": false, + "rule": 81, + "remote_identifier": "hunq5h" + } +}, +{ + "model": "core.post", + "pk": 3091, + "fields": { + "created": "2020-07-20T19:32:35.635Z", + "modified": "2020-07-20T19:32:35.645Z", + "title": "IS THAT A CUSTOMER? flop flop flop flop .... \" Can I uhh... help you sir?\"", + "body": "
      ", + "author": "MBMV", + "publication_date": "2020-07-20T12:50:40Z", + "url": "https://www.reddit.com/r/aww/comments/huj7g3/is_that_a_customer_flop_flop_flop_flop_can_i_uhh/", + "read": false, + "rule": 81, + "remote_identifier": "huj7g3" + } +}, +{ + "model": "core.post", + "pk": 3092, + "fields": { + "created": "2020-07-20T19:32:35.635Z", + "modified": "2020-07-20T19:32:35.647Z", + "title": "Good Boy turned Disney Princess", + "body": "
      ", + "author": "Sauwercraud", + "publication_date": "2020-07-20T18:40:05Z", + "url": "https://www.reddit.com/r/aww/comments/huojq0/good_boy_turned_disney_princess/", + "read": false, + "rule": 81, + "remote_identifier": "huojq0" + } +}, +{ + "model": "core.post", + "pk": 3093, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.649Z", + "title": "Kitty loop", + "body": "
      ", + "author": "Dlatrex", + "publication_date": "2020-07-20T12:54:02Z", + "url": "https://www.reddit.com/r/aww/comments/huj8s6/kitty_loop/", + "read": false, + "rule": 81, + "remote_identifier": "huj8s6" + } +}, +{ + "model": "core.post", + "pk": 3094, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.652Z", + "title": "if i fits i sits", + "body": "
      ", + "author": "jasontaken", + "publication_date": "2020-07-20T16:38:32Z", + "url": "https://www.reddit.com/r/aww/comments/humdlf/if_i_fits_i_sits/", + "read": false, + "rule": 81, + "remote_identifier": "humdlf" + } +}, +{ + "model": "core.post", + "pk": 3095, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.654Z", + "title": "Isn\u2019t she Adorable !", + "body": "
      \"Isn\u2019t
      ", + "author": "MunchyMac", + "publication_date": "2020-07-20T16:18:05Z", + "url": "https://www.reddit.com/r/aww/comments/hum133/isnt_she_adorable/", + "read": false, + "rule": 81, + "remote_identifier": "hum133" + } +}, +{ + "model": "core.post", + "pk": 3096, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.655Z", + "title": "Thank you mama (\u2283\uff61\u2022\u0301\u203f\u2022\u0300\uff61)\u2283", + "body": "
      ", + "author": "AnoushkaSingh", + "publication_date": "2020-07-20T13:35:51Z", + "url": "https://www.reddit.com/r/aww/comments/hujpxy/thank_you_mama/", + "read": false, + "rule": 81, + "remote_identifier": "hujpxy" + } +}, +{ + "model": "core.post", + "pk": 3097, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.657Z", + "title": "I WANT TO HUG HIM SO BAD!!!", + "body": "
      ", + "author": "BATMAN_5777", + "publication_date": "2020-07-20T18:25:20Z", + "url": "https://www.reddit.com/r/aww/comments/huo9z4/i_want_to_hug_him_so_bad/", + "read": false, + "rule": 81, + "remote_identifier": "huo9z4" + } +}, +{ + "model": "core.post", + "pk": 3098, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.659Z", + "title": "Before and after being called a good boy", + "body": "
      \"Before
      ", + "author": "vladgrinch", + "publication_date": "2020-07-20T10:48:40Z", + "url": "https://www.reddit.com/r/aww/comments/huhwu9/before_and_after_being_called_a_good_boy/", + "read": false, + "rule": 81, + "remote_identifier": "huhwu9" + } +}, +{ + "model": "core.post", + "pk": 3099, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.662Z", + "title": "My fianc\u00e9 has wanted a dog his whole life. This is his college graduation present. Welcome home Maple!", + "body": "
      \"My
      ", + "author": "AlexisaurusRex", + "publication_date": "2020-07-20T17:57:25Z", + "url": "https://www.reddit.com/r/aww/comments/hunrie/my_fianc\u00e9_has_wanted_a_dog_his_whole_life_this_is/", + "read": false, + "rule": 81, + "remote_identifier": "hunrie" + } +}, +{ + "model": "core.post", + "pk": 3100, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.664Z", + "title": "Cute burro.", + "body": "
      \"Cute
      ", + "author": "Craftmine101", + "publication_date": "2020-07-20T13:45:32Z", + "url": "https://www.reddit.com/r/aww/comments/huju40/cute_burro/", + "read": false, + "rule": 81, + "remote_identifier": "huju40" + } +}, +{ + "model": "core.post", + "pk": 3101, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.666Z", + "title": "I've never seen anyone dance better than that turtle.", + "body": "
      ", + "author": "Ashley1023", + "publication_date": "2020-07-20T18:07:30Z", + "url": "https://www.reddit.com/r/aww/comments/hunya8/ive_never_seen_anyone_dance_better_than_that/", + "read": false, + "rule": 81, + "remote_identifier": "hunya8" + } +}, +{ + "model": "core.post", + "pk": 3102, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.669Z", + "title": "Someone\u2019s going to be quite surprised when he realizes all this new stuff isn\u2019t for him!", + "body": "
      \"Someone\u2019s
      ", + "author": "molly590", + "publication_date": "2020-07-20T15:46:21Z", + "url": "https://www.reddit.com/r/aww/comments/hulikg/someones_going_to_be_quite_surprised_when_he/", + "read": false, + "rule": 81, + "remote_identifier": "hulikg" + } +}, +{ + "model": "core.post", + "pk": 3103, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.671Z", + "title": "my aunt asked me to paint her puppy and I think it turned out so cute!!!", + "body": "
      \"my
      ", + "author": "PineappleLightt", + "publication_date": "2020-07-20T16:39:37Z", + "url": "https://www.reddit.com/r/aww/comments/humea0/my_aunt_asked_me_to_paint_her_puppy_and_i_think/", + "read": false, + "rule": 81, + "remote_identifier": "humea0" + } +}, +{ + "model": "core.post", + "pk": 3104, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.673Z", + "title": "Master Assassin", + "body": "
      \"Master
      ", + "author": "LauWalker", + "publication_date": "2020-07-20T18:47:52Z", + "url": "https://www.reddit.com/r/aww/comments/huop8a/master_assassin/", + "read": false, + "rule": 81, + "remote_identifier": "huop8a" + } +}, +{ + "model": "core.post", + "pk": 3105, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.675Z", + "title": "Every time this tank cleaner cleans out the aquarium, this fish swims over to him looking for pets", + "body": "", + "author": "unnaturalorder", + "publication_date": "2020-07-20T05:29:30Z", + "url": "https://www.reddit.com/r/aww/comments/hue3r0/every_time_this_tank_cleaner_cleans_out_the/", + "read": false, + "rule": 81, + "remote_identifier": "hue3r0" + } +}, +{ + "model": "core.post", + "pk": 3106, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.678Z", + "title": "My girlfriend sent me this while I was at work. And here I was thinking the perfect picture of our dog didn't exist", + "body": "", + "author": "Khuma-zi_Eldrama", + "publication_date": "2020-07-20T19:22:48Z", + "url": "https://www.reddit.com/r/aww/comments/hupdz8/my_girlfriend_sent_me_this_while_i_was_at_work/", + "read": false, + "rule": 81, + "remote_identifier": "hupdz8" + } +}, +{ + "model": "core.post", + "pk": 3107, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.680Z", + "title": "My first ever post, everyone meet my new baby girl Kiora! I\u2019m so in love with her\ud83e\udd7a\ud83d\udcab", + "body": "
      \"My
      ", + "author": "Dumpling2463", + "publication_date": "2020-07-20T05:34:29Z", + "url": "https://www.reddit.com/r/aww/comments/hue6dx/my_first_ever_post_everyone_meet_my_new_baby_girl/", + "read": false, + "rule": 81, + "remote_identifier": "hue6dx" + } +}, +{ + "model": "core.post", + "pk": 3108, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.682Z", + "title": "Dog splashing in water", + "body": "", + "author": "TheRikari", + "publication_date": "2020-07-20T15:44:02Z", + "url": "https://www.reddit.com/r/aww/comments/hulh8k/dog_splashing_in_water/", + "read": false, + "rule": 81, + "remote_identifier": "hulh8k" + } +}, +{ + "model": "core.post", + "pk": 3109, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.685Z", + "title": "They say taking breaks is the key to productivity!", + "body": "
      ", + "author": "Thereaper29", + "publication_date": "2020-07-20T05:43:40Z", + "url": "https://www.reddit.com/r/aww/comments/hueawt/they_say_taking_breaks_is_the_key_to_productivity/", + "read": false, + "rule": 81, + "remote_identifier": "hueawt" + } +}, +{ + "model": "core.post", + "pk": 3110, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.687Z", + "title": "I went away for 3 weeks, and now my cat is in love with my husband", + "body": "
      \"I
      ", + "author": "sillykittyish", + "publication_date": "2020-07-20T03:29:11Z", + "url": "https://www.reddit.com/r/aww/comments/hucd7u/i_went_away_for_3_weeks_and_now_my_cat_is_in_love/", + "read": false, + "rule": 81, + "remote_identifier": "hucd7u" + } +}, +{ + "model": "core.post", + "pk": 3111, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.689Z", + "title": "Can you feel the love", + "body": "
      ", + "author": "kettySewrdPic", + "publication_date": "2020-07-20T09:13:32Z", + "url": "https://www.reddit.com/r/aww/comments/hugx1k/can_you_feel_the_love/", + "read": false, + "rule": 81, + "remote_identifier": "hugx1k" + } +}, +{ + "model": "core.post", + "pk": 3112, + "fields": { + "created": "2020-07-20T19:32:35.835Z", + "modified": "2020-07-21T20:14:50.522Z", + "title": "Linux Experiences/Rants or Education/Certifications thread - July 20, 2020", + "body": "

      Welcome to r/linux rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.

      \n\n

      Let us know what's annoying you, whats making you happy, or something that you want to get out to r/linux but didn't make the cut into a full post of it's own.

      \n\n

      For those looking for certifications please use this megathread to ask about how to get certified whether it's for the business world or for your own satisfaction. Be sure to check out r/linuxadmin for more discussion in the SysAdmin world!

      \n\n

      Please keep questions in r/linuxquestions, r/linux4noobs, or the Wednesday automod thread.

      \n
      ", + "author": "AutoModerator", + "publication_date": "2020-07-20T06:12:00Z", + "url": "https://www.reddit.com/r/linux/comments/hueoo0/linux_experiencesrants_or_educationcertifications/", + "read": false, + "rule": 80, + "remote_identifier": "hueoo0" + } +}, +{ + "model": "core.post", + "pk": 3113, + "fields": { + "created": "2020-07-20T19:32:35.836Z", + "modified": "2020-07-21T20:19:49.339Z", + "title": "Unix Family Tree", + "body": "
      \"Unix
      ", + "author": "bauripalash", + "publication_date": "2020-07-20T10:32:15Z", + "url": "https://www.reddit.com/r/linux/comments/huhqrh/unix_family_tree/", + "read": true, + "rule": 80, + "remote_identifier": "huhqrh" + } +}, +{ + "model": "core.post", + "pk": 3114, + "fields": { + "created": "2020-07-20T19:32:35.836Z", + "modified": "2020-07-21T20:14:50.554Z", + "title": "NVIDIA open sourced part of NVAPI SDK to aid 'Windows emulation environments'", + "body": "", + "author": "ignapk", + "publication_date": "2020-07-20T13:17:19Z", + "url": "https://www.reddit.com/r/linux/comments/huji8c/nvidia_open_sourced_part_of_nvapi_sdk_to_aid/", + "read": false, + "rule": 80, + "remote_identifier": "huji8c" + } +}, +{ + "model": "core.post", + "pk": 3115, + "fields": { + "created": "2020-07-20T19:32:35.836Z", + "modified": "2020-07-21T20:14:50.551Z", + "title": "Jellyfin 10.6 released", + "body": "", + "author": "resoluti0n_", + "publication_date": "2020-07-20T16:40:05Z", + "url": "https://www.reddit.com/r/linux/comments/humekr/jellyfin_106_released/", + "read": false, + "rule": 80, + "remote_identifier": "humekr" + } +}, +{ + "model": "core.post", + "pk": 3116, + "fields": { + "created": "2020-07-20T19:32:35.836Z", + "modified": "2020-07-21T20:14:50.583Z", + "title": "[German] Article in major german newspaper about trying Linux and WSL. Literal: \"Why it's beneficial to try Linux now\"", + "body": "", + "author": "noname7890", + "publication_date": "2020-07-19T15:19:27Z", + "url": "https://www.reddit.com/r/linux/comments/hu0d5v/german_article_in_major_german_newspaper_about/", + "read": false, + "rule": 80, + "remote_identifier": "hu0d5v" + } +}, +{ + "model": "core.post", + "pk": 3117, + "fields": { + "created": "2020-07-20T19:32:35.837Z", + "modified": "2020-07-21T20:14:50.574Z", + "title": "Brian Kernighan: UNIX, C, AWK, AMPL, and Go Programming | AI Podcast #109 with Lex Fridman", + "body": "", + "author": "tinyatom", + "publication_date": "2020-07-20T08:48:35Z", + "url": "https://www.reddit.com/r/linux/comments/hugn0w/brian_kernighan_unix_c_awk_ampl_and_go/", + "read": false, + "rule": 80, + "remote_identifier": "hugn0w" + } +}, +{ + "model": "core.post", + "pk": 3118, + "fields": { + "created": "2020-07-20T19:32:35.837Z", + "modified": "2020-07-21T20:14:50.578Z", + "title": "Explaining Computers Host Christopher Barnatt Has Switched To Linux", + "body": "", + "author": "sysrpl", + "publication_date": "2020-07-20T13:00:02Z", + "url": "https://www.reddit.com/r/linux/comments/hujb12/explaining_computers_host_christopher_barnatt_has/", + "read": false, + "rule": 80, + "remote_identifier": "hujb12" + } +}, +{ + "model": "core.post", + "pk": 3119, + "fields": { + "created": "2020-07-20T19:32:35.837Z", + "modified": "2020-07-21T20:14:50.529Z", + "title": "Ireland donates contact tracing app to the Linux foundation.", + "body": "", + "author": "mathiasryan", + "publication_date": "2020-07-20T21:31:43Z", + "url": "https://www.reddit.com/r/linux/comments/hury4e/ireland_donates_contact_tracing_app_to_the_linux/", + "read": false, + "rule": 80, + "remote_identifier": "hury4e" + } +}, +{ + "model": "core.post", + "pk": 3120, + "fields": { + "created": "2020-07-20T19:32:35.842Z", + "modified": "2020-07-21T20:14:50.588Z", + "title": "I implemented a simple terminal-based password manager", + "body": "

      I created a simple, secure, and free password manager written in C: SaltPass. I haven't contributed open source code before, but I think this might be useful to a few people. Especially as an alternative to paid solutions such as LastPass and the likes. Any suggestions/edits/code improvements would be greatly appreciated!

      \n
      ", + "author": "zaid-gg", + "publication_date": "2020-07-20T07:43:03Z", + "url": "https://www.reddit.com/r/linux/comments/hufula/i_implemented_a_simple_terminalbased_password/", + "read": false, + "rule": 80, + "remote_identifier": "hufula" + } +}, +{ + "model": "core.post", + "pk": 3121, + "fields": { + "created": "2020-07-20T19:32:35.843Z", + "modified": "2020-07-21T20:14:50.593Z", + "title": "Performance analysis of multi services on container Docker, LXC, and LXD - Bulletin of Electrical Engineering and Informatics, Adinda Riztia Putri, Rendy Munadi, Ridha Muldina Negara Adaptive Network\u2026", + "body": "", + "author": "bmullan", + "publication_date": "2020-07-20T11:35:59Z", + "url": "https://www.reddit.com/r/linux/comments/huieio/performance_analysis_of_multi_services_on/", + "read": false, + "rule": 80, + "remote_identifier": "huieio" + } +}, +{ + "model": "core.post", + "pk": 3122, + "fields": { + "created": "2020-07-20T19:32:35.844Z", + "modified": "2020-07-21T20:14:50.602Z", + "title": "Create an Internal PKI using OpenSSL and NitroKey HSM", + "body": "", + "author": "PixelPaulaus", + "publication_date": "2020-07-20T06:18:41Z", + "url": "https://www.reddit.com/r/linux/comments/huerpn/create_an_internal_pki_using_openssl_and_nitrokey/", + "read": false, + "rule": 80, + "remote_identifier": "huerpn" + } +}, +{ + "model": "core.post", + "pk": 3123, + "fields": { + "created": "2020-07-20T19:32:35.844Z", + "modified": "2020-07-20T19:32:35.883Z", + "title": "vopono - run applications via VPNs with temporary network namespaces", + "body": "", + "author": "nivenkos", + "publication_date": "2020-07-19T20:02:57Z", + "url": "https://www.reddit.com/r/linux/comments/hu4vge/vopono_run_applications_via_vpns_with_temporary/", + "read": false, + "rule": 80, + "remote_identifier": "hu4vge" + } +}, +{ + "model": "core.post", + "pk": 3124, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.886Z", + "title": "Double (triple, quadruple...) internet speed with openvpn tap channel bonding to a linux VPS", + "body": "

      I have been working a couple of days on my latest video about channel bonding - the video is heavily inspired be this article on Serverfault. In essence, I have been searching for a while on how to bond multiple VPN channels together in order to increase internet speed - there does not seem to be a lot of information around - mainly articles on forums and reddit state that it should be possible but a detailed guide is hard to find. I am using two Ubuntu machines in order to build the connection - one local and one VPS. The bash scripts I use in my video in order to achieve tap channel bonding are available on my github repository. I am currently working on a second video in order to walk through and explain the scripts in depth. Enjoy!

      \n\n

      (EDIT) - the question has come up in the discussions below if this is really packet load balancing or rather balancing links only - please see my comment further down - I can confirm that this DOES packet balancing so it does work as described.

      \n
      ", + "author": "onemarcfifty", + "publication_date": "2020-07-19T20:41:40Z", + "url": "https://www.reddit.com/r/linux/comments/hu5l4f/double_triple_quadruple_internet_speed_with/", + "read": false, + "rule": 80, + "remote_identifier": "hu5l4f" + } +}, +{ + "model": "core.post", + "pk": 3125, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.888Z", + "title": "OpenRGB - Open source RGB lighting control that doesn't depend on manufacturer software, supports Linux", + "body": "", + "author": "pr0_c0d3", + "publication_date": "2020-07-18T16:52:48Z", + "url": "https://www.reddit.com/r/linux/comments/hthuli/openrgb_open_source_rgb_lighting_control_that/", + "read": false, + "rule": 80, + "remote_identifier": "hthuli" + } +}, +{ + "model": "core.post", + "pk": 3126, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.890Z", + "title": "Make this any sense? Automatic CPU Speed & Power Optimizer", + "body": "", + "author": "spite77", + "publication_date": "2020-07-20T11:53:35Z", + "url": "https://www.reddit.com/r/linux/comments/huikxz/make_this_any_sense_automatic_cpu_speed_power/", + "read": false, + "rule": 80, + "remote_identifier": "huikxz" + } +}, +{ + "model": "core.post", + "pk": 3127, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.891Z", + "title": "Let\u2019s not be pedantic about \u201cOpen Source\u201d", + "body": "", + "author": "speckz", + "publication_date": "2020-07-20T16:46:43Z", + "url": "https://www.reddit.com/r/linux/comments/humirw/lets_not_be_pedantic_about_open_source/", + "read": false, + "rule": 80, + "remote_identifier": "humirw" + } +}, +{ + "model": "core.post", + "pk": 3128, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.893Z", + "title": "Experiences with running Linux Lite", + "body": "", + "author": "daemonpenguin", + "publication_date": "2020-07-20T02:43:49Z", + "url": "https://www.reddit.com/r/linux/comments/hubonw/experiences_with_running_linux_lite/", + "read": false, + "rule": 80, + "remote_identifier": "hubonw" + } +}, +{ + "model": "core.post", + "pk": 3129, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.895Z", + "title": "Tried gnome on arch, surprised how lean it is (used flameshot so it used about 72mb more) closing at 600 megs) on fedora and pop i had gnome eating up 1.3gigs at boot up.", + "body": "
      \"Tried
      ", + "author": "V1n0dKr1shna", + "publication_date": "2020-07-18T13:54:55Z", + "url": "https://www.reddit.com/r/linux/comments/htfeph/tried_gnome_on_arch_surprised_how_lean_it_is_used/", + "read": false, + "rule": 80, + "remote_identifier": "htfeph" + } +}, +{ + "model": "core.post", + "pk": 3130, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.897Z", + "title": "The Free Software Foundation is holding a Fundraiser, help them reach 200 members", + "body": "", + "author": "Neet-Feet", + "publication_date": "2020-07-18T17:55:30Z", + "url": "https://www.reddit.com/r/linux/comments/htiuyi/the_free_software_foundation_is_holding_a/", + "read": false, + "rule": 80, + "remote_identifier": "htiuyi" + } +}, +{ + "model": "core.post", + "pk": 3131, + "fields": { + "created": "2020-07-20T19:32:35.853Z", + "modified": "2020-07-20T19:32:35.899Z", + "title": "Why is the mindset around Arch so negative?", + "body": "

      I love the Linux community as a whole. You can find some of the most creative and imaginative people within most Linux communities. On a whole, Linux users are some of the most helpful and informative people you can encounter. Truly the type to think outside the box and learn new things. It can be very inspirational.

      \n\n

      If I jumped onto Ubuntu, Fedora, or openSUSE's community I can have a free flowing conversation about Linux, their distribution, and getting help or giving help is so free-flowing and easy. The communities are eager to welcome new people and appreciate folks who contribute.

      \n\n

      Then you have Arch. I love the OS but dislike the mindset. Asking for help is meat with resistance, giving help can also be punishable, and god forbid you try to have a discussion. But it's not just their core community either. For example, I just discovered Endeavour OS which is built around Arch and after 11 post I'm told to come back in 8 hours. Their subReddit here on Reddit, you have to ask to even make 1 post. There of course is also Manjaro Linux and they too have this gatekeeper mindset, the same can be said for ArcoLinux.

      \n\n

      What is it about Arch that makes everyone want to be either a control freak or a gatekeeper?

      \n\n

      I do not see this within the Ubuntu or Fedora or openSUSE communities. As I said, their mindset seems eager and willing to unite and work as a community. Am I the only how has noticed this?

      \n
      ", + "author": "Linux-Is-Best", + "publication_date": "2020-07-18T23:28:12Z", + "url": "https://www.reddit.com/r/linux/comments/htojwk/why_is_the_mindset_around_arch_so_negative/", + "read": false, + "rule": 80, + "remote_identifier": "htojwk" + } +}, +{ + "model": "core.post", + "pk": 3132, + "fields": { + "created": "2020-07-20T19:32:35.853Z", + "modified": "2020-07-20T19:32:35.901Z", + "title": "Using the nstat network statistics command in Linux", + "body": "", + "author": "cronos426", + "publication_date": "2020-07-19T17:55:55Z", + "url": "https://www.reddit.com/r/linux/comments/hu2q6v/using_the_nstat_network_statistics_command_in/", + "read": false, + "rule": 80, + "remote_identifier": "hu2q6v" + } +}, +{ + "model": "core.post", + "pk": 3133, + "fields": { + "created": "2020-07-20T19:32:35.853Z", + "modified": "2020-07-20T19:32:35.903Z", + "title": "Contributing via GitLab Merge Requests", + "body": "", + "author": "ChristophCullmann", + "publication_date": "2020-07-18T20:01:26Z", + "url": "https://www.reddit.com/r/linux/comments/htl05p/contributing_via_gitlab_merge_requests/", + "read": false, + "rule": 80, + "remote_identifier": "htl05p" + } +}, +{ + "model": "core.post", + "pk": 3134, + "fields": { + "created": "2020-07-20T19:32:35.853Z", + "modified": "2020-07-20T19:32:35.905Z", + "title": "OpenMandriva: combines WINE64 and 32 into one package capable of running both binaries, i686 architecture was considered as deprecated. Work is underway on a new Rolling release", + "body": "", + "author": "DamonsLinux", + "publication_date": "2020-07-18T15:02:35Z", + "url": "https://www.reddit.com/r/linux/comments/htg9dj/openmandriva_combines_wine64_and_32_into_one/", + "read": false, + "rule": 80, + "remote_identifier": "htg9dj" + } +}, +{ + "model": "core.post", + "pk": 3135, + "fields": { + "created": "2020-07-20T19:32:35.853Z", + "modified": "2020-07-20T19:32:35.906Z", + "title": "OpenRCT2 Player Survey 2020 - Previous survey shows almost 25% players are linux, please help represent linux in the most recent survey", + "body": "", + "author": "christophski", + "publication_date": "2020-07-18T11:39:06Z", + "url": "https://www.reddit.com/r/linux/comments/htdzuh/openrct2_player_survey_2020_previous_survey_shows/", + "read": false, + "rule": 80, + "remote_identifier": "htdzuh" + } +}, +{ + "model": "core.post", + "pk": 3136, + "fields": { + "created": "2020-07-20T19:32:35.853Z", + "modified": "2020-07-20T19:32:35.908Z", + "title": "This week in KDE: Get New Stuff fixes and more", + "body": "", + "author": "kyentei", + "publication_date": "2020-07-18T10:03:46Z", + "url": "https://www.reddit.com/r/linux/comments/htd1an/this_week_in_kde_get_new_stuff_fixes_and_more/", + "read": false, + "rule": 80, + "remote_identifier": "htd1an" + } +}, +{ + "model": "core.post", + "pk": 3137, + "fields": { + "created": "2020-07-20T19:32:35.857Z", + "modified": "2020-07-20T19:32:35.910Z", + "title": "Blender Runs on Linux Pinephone", + "body": "

      I managed to get the desktop version of Blender on the Pinephone, and it works really well except for a few bugs.

      \n\n

      See my post on r/blender:

      \n\n

      https://www.reddit.com/r/blender/comments/hsxv27/i_installed_blender_on_a_phone/

      \n\n

      and r/PINE64official:

      \n\n

      https://www.reddit.com/r/PINE64official/comments/hsxc33/blender_on_pine_phone_almost_usable/

      \n\n

      I've tried other desktop programs like Xournal and PPSSPP, their UIs also work well, I'd be able to do even more if OpenGL 3 was working.

      \n
      ", + "author": "InfiniteHawk", + "publication_date": "2020-07-17T22:35:14Z", + "url": "https://www.reddit.com/r/linux/comments/ht3d4k/blender_runs_on_linux_pinephone/", + "read": false, + "rule": 80, + "remote_identifier": "ht3d4k" + } +}, +{ + "model": "core.post", + "pk": 3138, + "fields": { + "created": "2020-07-21T20:14:50.415Z", + "modified": "2020-07-21T20:18:21.616Z", + "title": "Hrmmm They Need to Fix Throttle Animations in the Sabre", + "body": "
      ", + "author": "TheBootRanger", + "publication_date": "2020-07-21T13:26:01Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv5omc/hrmmm_they_need_to_fix_throttle_animations_in_the/", + "read": true, + "rule": 82, + "remote_identifier": "hv5omc" + } +}, +{ + "model": "core.post", + "pk": 3139, + "fields": { + "created": "2020-07-21T20:14:50.415Z", + "modified": "2020-07-21T20:18:49.999Z", + "title": "My first 3.10 landing could have gone better...", + "body": "
      ", + "author": "KnLfey", + "publication_date": "2020-07-21T16:04:50Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv7w85/my_first_310_landing_could_have_gone_better/", + "read": true, + "rule": 82, + "remote_identifier": "hv7w85" + } +}, +{ + "model": "core.post", + "pk": 3140, + "fields": { + "created": "2020-07-21T20:14:50.415Z", + "modified": "2020-07-21T20:14:50.439Z", + "title": "How about the Christmas in 3 more years?", + "body": "
      \"How
      ", + "author": "SpleanEater", + "publication_date": "2020-07-21T17:49:22Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv9qy8/how_about_the_christmas_in_3_more_years/", + "read": false, + "rule": 82, + "remote_identifier": "hv9qy8" + } +}, +{ + "model": "core.post", + "pk": 3141, + "fields": { + "created": "2020-07-21T20:14:50.415Z", + "modified": "2020-07-21T20:18:33.532Z", + "title": "Long time Elite Dangerous player. New to star citizen i think im doing great", + "body": "", + "author": "Filblo5", + "publication_date": "2020-07-21T15:33:49Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv7elb/long_time_elite_dangerous_player_new_to_star/", + "read": true, + "rule": 82, + "remote_identifier": "hv7elb" + } +}, +{ + "model": "core.post", + "pk": 3142, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:14:50.443Z", + "title": "And we stand by it.", + "body": "
      \"And
      ", + "author": "CyberTill", + "publication_date": "2020-07-21T18:57:48Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvb3wm/and_we_stand_by_it/", + "read": false, + "rule": 82, + "remote_identifier": "hvb3wm" + } +}, +{ + "model": "core.post", + "pk": 3143, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:14:50.446Z", + "title": "Nomad", + "body": "
      \"Nomad\"
      ", + "author": "ibracitizen", + "publication_date": "2020-07-21T19:52:24Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvc5h3/nomad/", + "read": false, + "rule": 82, + "remote_identifier": "hvc5h3" + } +}, +{ + "model": "core.post", + "pk": 3144, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:14:50.449Z", + "title": "Probably the best screen cap i've ever caught on a whim. 3.5 Arc Corp release. Also a confession: I never pledged. Got a ship with my GPU. I intend to pay my dues.", + "body": "
      \"Probably
      ", + "author": "ScionoicS", + "publication_date": "2020-07-21T20:23:01Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvcqzf/probably_the_best_screen_cap_ive_ever_caught_on_a/", + "read": false, + "rule": 82, + "remote_identifier": "hvcqzf" + } +}, +{ + "model": "core.post", + "pk": 3145, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:14:50.451Z", + "title": "Play to escape the depressing job hunt where I need 10 years experience for a entry level job to find this, only been playing for 1 and a half years :(", + "body": "
      \"Play
      ", + "author": "Albert-III-", + "publication_date": "2020-07-21T12:23:45Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv4z08/play_to_escape_the_depressing_job_hunt_where_i/", + "read": false, + "rule": 82, + "remote_identifier": "hv4z08" + } +}, +{ + "model": "core.post", + "pk": 3146, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:19:00.691Z", + "title": "The void beckons.", + "body": "
      ", + "author": "HisNameWasHis", + "publication_date": "2020-07-21T14:40:51Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv6nij/the_void_beckons/", + "read": true, + "rule": 82, + "remote_identifier": "hv6nij" + } +}, +{ + "model": "core.post", + "pk": 3147, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:19:05.881Z", + "title": "I made a SC-like Photobash with Soldiers", + "body": "
      \"I
      ", + "author": "IsaacPolar", + "publication_date": "2020-07-21T17:13:39Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv92ri/i_made_a_sclike_photobash_with_soldiers/", + "read": true, + "rule": 82, + "remote_identifier": "hv92ri" + } +}, +{ + "model": "core.post", + "pk": 3148, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:19:41.227Z", + "title": "Ocean Shader Improvements", + "body": "
      \"Ocean
      ", + "author": "shoeii", + "publication_date": "2020-07-21T18:41:51Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvasds/ocean_shader_improvements/", + "read": true, + "rule": 82, + "remote_identifier": "hvasds" + } +}, +{ + "model": "core.post", + "pk": 3149, + "fields": { + "created": "2020-07-21T20:14:50.420Z", + "modified": "2020-07-21T20:14:50.459Z", + "title": "As much shit as Star Citizen (rightfully) gets it still does one thing better than any other 'game' I've played", + "body": "

      It invokes a real sense of scale, on multiple levels.

      \n\n

      One could argue that's one of the most important feelings you'd want to capture in any game set in space, but of course it's mostly meaningless if there aren't enough gameplay loops and systems in place to work in tandem with and make the space that's been created interesting, and that's where SC is currently a failure.

      \n\n

      Even so, I think being able to create that sense of smallness isn't insignificant.

      \n\n

      You as a pilot are dwarfed by your ship which is itself dwarfed by a larger ship which is itself dwarfed by another, even more massive one which is dwarfed by the space station or hub you're at which is dwarfed by a crater on a moon which is dwarfed by the moon itself which is dwarfed by the planet it orbits which is dwarfed by the sheer vastness of space in between all of those things and that they are, despite the distance, still connected.

      \n\n

      Getting lost in Lorville (even if it is mostly linear) and knowing it's only a small part of the playable space is a really neat feeling - looking out from the windows of the train up into the sky and knowing you can go there and beyond really makes you feel like there is a whole world (and more) waiting to be explored.

      \n\n

      I think this is a direct result of having legs and not being locked into the cockpit of your ship - I've played more Elite: Dangerous than Star Citizen and it accomplishes a similar sense of scale but, at least not as far as I've felt, never to the same degree - because you're locked in your ship you never really get this same sense of being small or insignificant even though you are dwarfed in similar ways by planets/asteroids/other ships - will be interesting to see how their implementation of 'space legs' in the upcoming expansion changes this.

      \n\n

      My favourite thing to do in Star Citizen (because there isn't a whole lot) is to just find some pocket of space far away from anything else and just walk around my ship, feeling truly alone and insignificant, gazing out at the void that stretches infinitely all around - something about that is super comfy.

      \n\n

      I can't think of many other game that accomplish a similar level of scale though I'm sure they exist.

      \n\n

      I've been playing an indie game called Empyrion - Galactic Survival and it actually is sort of similar to SC in this regard but it's nowhere near as polished or smooth - transitions from atmosphere to space are not truly seamless and planets themselves are kind of stitched together, but it still manages to invoke that same kind of awe at the scale of things when you dock a small vessel to a capital vessel, for example - definitely worth checking out if you like sci-fi/space games, which you must if you're here, but just be prepared for the jank.

      \n
      ", + "author": "thegreatself", + "publication_date": "2020-07-21T20:30:15Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvcw38/as_much_shit_as_star_citizen_rightfully_gets_it/", + "read": false, + "rule": 82, + "remote_identifier": "hvcw38" + } +}, +{ + "model": "core.post", + "pk": 3150, + "fields": { + "created": "2020-07-21T20:14:50.420Z", + "modified": "2020-07-21T20:14:50.462Z", + "title": "You waiting for patch 3.10 to go live while watching tons of videos about the new flight model features. Be patient, 3.11 and 3.12 will be even better.", + "body": "
      \"You
      ", + "author": "jsabater76", + "publication_date": "2020-07-21T09:39:27Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv372v/you_waiting_for_patch_310_to_go_live_while/", + "read": false, + "rule": 82, + "remote_identifier": "hv372v" + } +}, +{ + "model": "core.post", + "pk": 3151, + "fields": { + "created": "2020-07-21T20:14:50.420Z", + "modified": "2020-07-21T20:14:50.466Z", + "title": "CIG, can we please fix these \"black hole\" doors(when they are closed) on ships please.", + "body": "
      \"CIG,
      ", + "author": "AbnormallyBendPenis", + "publication_date": "2020-07-21T13:40:14Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv5uzj/cig_can_we_please_fix_these_black_hole_doorswhen/", + "read": false, + "rule": 82, + "remote_identifier": "hv5uzj" + } +}, +{ + "model": "core.post", + "pk": 3152, + "fields": { + "created": "2020-07-21T20:14:50.420Z", + "modified": "2020-07-21T20:14:50.468Z", + "title": "Anvil Super Hornet over Cellin", + "body": "
      \"Anvil
      ", + "author": "SaraCaterina", + "publication_date": "2020-07-21T20:33:58Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvcyq6/anvil_super_hornet_over_cellin/", + "read": false, + "rule": 82, + "remote_identifier": "hvcyq6" + } +}, +{ + "model": "core.post", + "pk": 3153, + "fields": { + "created": "2020-07-21T20:14:50.420Z", + "modified": "2020-07-21T20:14:50.471Z", + "title": "3.10 Combat Changes", + "body": "", + "author": "STLYoungblood", + "publication_date": "2020-07-21T16:37:44Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv8fr7/310_combat_changes/", + "read": false, + "rule": 82, + "remote_identifier": "hv8fr7" + } +}, +{ + "model": "core.post", + "pk": 3154, + "fields": { + "created": "2020-07-21T20:14:50.420Z", + "modified": "2020-07-21T20:14:50.472Z", + "title": "Hey CIG how about that S42 Vi.... Oh...", + "body": "
      \"Hey
      ", + "author": "SiEDeN", + "publication_date": "2020-07-21T21:37:16Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hve6am/hey_cig_how_about_that_s42_vi_oh/", + "read": false, + "rule": 82, + "remote_identifier": "hve6am" + } +}, +{ + "model": "core.post", + "pk": 3155, + "fields": { + "created": "2020-07-21T20:14:50.422Z", + "modified": "2020-07-21T20:14:50.475Z", + "title": "3.10 M PTU Eclipse improvements", + "body": "

      If this goes live, CIG had addressed 2 of my Eclipse critics.

      \n\n

      Not because of my videos of course, CIG doesn't know I exist.

      \n\n

       

      \n\n

      a. Eclipse has armor stealth in 3.10, see my table:\nhttps://docs.google.com/spreadsheets/d/1OJXg7MQsG_IVTPsmlmZYaxEPK4n4iqnhQx4oigIlJHg/edit#gid=343807746

      \n\n

       

      \n\n

      b. Eclipse can fire her size 9 torpedoes way quicker now, see my video with a side by side comparison of the max firing speed in 3.9 and 3.10:\nhttps://youtu.be/GFTF1Qt7T3o?t=207

      \n
      ", + "author": "Camural", + "publication_date": "2020-07-21T18:15:50Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hva9lc/310_m_ptu_eclipse_improvements/", + "read": false, + "rule": 82, + "remote_identifier": "hva9lc" + } +}, +{ + "model": "core.post", + "pk": 3156, + "fields": { + "created": "2020-07-21T20:14:50.422Z", + "modified": "2020-07-21T20:14:50.477Z", + "title": "Hark! The Drake Herald Sings", + "body": "
      \"Hark!
      ", + "author": "CyrexStorm", + "publication_date": "2020-07-21T16:19:31Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv84kk/hark_the_drake_herald_sings/", + "read": false, + "rule": 82, + "remote_identifier": "hv84kk" + } +}, +{ + "model": "core.post", + "pk": 3157, + "fields": { + "created": "2020-07-21T20:14:50.422Z", + "modified": "2020-07-21T20:14:50.479Z", + "title": "The new flight stick in the Prowler", + "body": "
      \"The
      ", + "author": "Potato_Nades", + "publication_date": "2020-07-21T16:22:22Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv86c2/the_new_flight_stick_in_the_prowler/", + "read": false, + "rule": 82, + "remote_identifier": "hv86c2" + } +}, +{ + "model": "core.post", + "pk": 3158, + "fields": { + "created": "2020-07-21T20:14:50.422Z", + "modified": "2020-07-21T20:14:50.481Z", + "title": "Norwegian VAT charged from August 1st", + "body": "
      \"Norwegian
      ", + "author": "norgeek", + "publication_date": "2020-07-21T10:30:57Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv3r3l/norwegian_vat_charged_from_august_1st/", + "read": false, + "rule": 82, + "remote_identifier": "hv3r3l" + } +}, +{ + "model": "core.post", + "pk": 3159, + "fields": { + "created": "2020-07-21T20:14:50.423Z", + "modified": "2020-07-21T20:14:50.484Z", + "title": "With Pyro (currently WIP), Nyx (partially done), Odin (S42), currently on the way, what is everyone\u2019s thoughts on Terra possibly being next on the list of star systems to be added into the PU within \u2026", + "body": "
      \"With
      ", + "author": "realCLTotaku", + "publication_date": "2020-07-21T13:27:09Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv5p41/with_pyro_currently_wip_nyx_partially_done_odin/", + "read": false, + "rule": 82, + "remote_identifier": "hv5p41" + } +}, +{ + "model": "core.post", + "pk": 3160, + "fields": { + "created": "2020-07-21T20:14:50.423Z", + "modified": "2020-07-21T20:14:50.486Z", + "title": "Testing out the new electron rifle", + "body": "
      ", + "author": "joshbaker2112", + "publication_date": "2020-07-21T02:56:19Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huxr6d/testing_out_the_new_electron_rifle/", + "read": false, + "rule": 82, + "remote_identifier": "huxr6d" + } +}, +{ + "model": "core.post", + "pk": 3161, + "fields": { + "created": "2020-07-21T20:14:50.423Z", + "modified": "2020-07-21T20:14:50.487Z", + "title": "Imperial Geographic's Lovecraftian magazine special is here. \ud83d\udc19 Find the link in the comments!", + "body": "
      \"Imperial
      ", + "author": "Good_Punk2", + "publication_date": "2020-07-21T18:21:38Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvadrh/imperial_geographics_lovecraftian_magazine/", + "read": false, + "rule": 82, + "remote_identifier": "hvadrh" + } +}, +{ + "model": "core.post", + "pk": 3162, + "fields": { + "created": "2020-07-21T20:14:50.497Z", + "modified": "2020-07-21T20:14:50.525Z", + "title": "Linux Distributions Timeline", + "body": "
      \"Linux
      ", + "author": "bauripalash", + "publication_date": "2020-07-21T06:07:59Z", + "url": "https://www.reddit.com/r/linux/comments/hv0ktn/linux_distributions_timeline/", + "read": false, + "rule": 80, + "remote_identifier": "hv0ktn" + } +}, +{ + "model": "core.post", + "pk": 3163, + "fields": { + "created": "2020-07-21T20:14:50.497Z", + "modified": "2020-07-21T20:14:50.527Z", + "title": "Fedora: Proposal to replace default wined3d backend with DXVK", + "body": "", + "author": "friskfrugt", + "publication_date": "2020-07-21T19:42:49Z", + "url": "https://www.reddit.com/r/linux/comments/hvbyyr/fedora_proposal_to_replace_default_wined3d/", + "read": false, + "rule": 80, + "remote_identifier": "hvbyyr" + } +}, +{ + "model": "core.post", + "pk": 3164, + "fields": { + "created": "2020-07-21T20:14:50.497Z", + "modified": "2020-07-21T20:14:50.531Z", + "title": "Update on marketing and communication plans for the LibreOffice 7.x series", + "body": "", + "author": "TheQuantumZero", + "publication_date": "2020-07-21T09:59:23Z", + "url": "https://www.reddit.com/r/linux/comments/hv3erm/update_on_marketing_and_communication_plans_for/", + "read": false, + "rule": 80, + "remote_identifier": "hv3erm" + } +}, +{ + "model": "core.post", + "pk": 3165, + "fields": { + "created": "2020-07-21T20:14:50.497Z", + "modified": "2020-07-21T20:14:50.533Z", + "title": "FOSS job opening: LibreOffice Development Mentor at The Document Foundation", + "body": "", + "author": "themikeosguy", + "publication_date": "2020-07-21T14:26:36Z", + "url": "https://www.reddit.com/r/linux/comments/hv6gfw/foss_job_opening_libreoffice_development_mentor/", + "read": false, + "rule": 80, + "remote_identifier": "hv6gfw" + } +}, +{ + "model": "core.post", + "pk": 3166, + "fields": { + "created": "2020-07-21T20:14:50.503Z", + "modified": "2020-07-21T20:14:50.536Z", + "title": "gomd - quickly display formatted markdown files with code highlight in your browser", + "body": "

      Hi all!

      \n\n

      I wanted to share a project I've been working on recently. I think it reached a stage where it's pretty usable and should work out of the box. gomd sets up a HTTP server and serves a directory in your browser so you can quickly view your markdown files. It comes with some neat features like:

      \n\n
        \n
      • Monitoring files - it will monitor files for changes and reload them whenever needed
      • \n
      • Hot reloading - whenever the file you are currently viewing changes, the tab in your browser will reload automatically.
      • \n
      • Code Highlight - All blocks of code in most common languages will be color highlighted.
      • \n
      • Themes - choose from multiple themes like: solarized, monokai, github, dracula...
      • \n
      \n\n

      Link: gomd

      \n\n

      For now its only available from AUR or built from source.

      \n\n

      \n\n

      Any tips or feedback will be greatly appreciated :)

      \n
      ", + "author": "wwojtekk", + "publication_date": "2020-07-21T20:07:31Z", + "url": "https://www.reddit.com/r/linux/comments/hvcg44/gomd_quickly_display_formatted_markdown_files/", + "read": false, + "rule": 80, + "remote_identifier": "hvcg44" + } +}, +{ + "model": "core.post", + "pk": 3167, + "fields": { + "created": "2020-07-21T20:14:50.503Z", + "modified": "2020-07-21T20:14:50.543Z", + "title": "They're not otherwise wrong, but it didn't become a real Internet standard until 2017.", + "body": "
      \"They're
      ", + "author": "foodown", + "publication_date": "2020-07-21T21:39:09Z", + "url": "https://www.reddit.com/r/linux/comments/hve7l5/theyre_not_otherwise_wrong_but_it_didnt_become_a/", + "read": false, + "rule": 80, + "remote_identifier": "hve7l5" + } +}, +{ + "model": "core.post", + "pk": 3168, + "fields": { + "created": "2020-07-21T20:14:50.503Z", + "modified": "2020-07-21T20:14:50.545Z", + "title": "Drawing - an alternative to Paint for Linux (gtk3, support HiDPI)", + "body": "", + "author": "dontdieych", + "publication_date": "2020-07-21T02:37:22Z", + "url": "https://www.reddit.com/r/linux/comments/huxgsg/drawing_an_alternative_to_paint_for_linux_gtk3/", + "read": false, + "rule": 80, + "remote_identifier": "huxgsg" + } +}, +{ + "model": "core.post", + "pk": 3169, + "fields": { + "created": "2020-07-21T20:14:50.509Z", + "modified": "2020-07-21T20:14:50.547Z", + "title": "Observations on a Linux issue with 3.5mm earphones with a mic", + "body": "

      Alright hello. I have come from r/SolusProject and I made a post there to do with headphone issues. I suggest you read through the post and comments to get a better understanding before reading this https://www.reddit.com/r/SolusProject/comments/hsql4d/frustrating_headphone_issues/. I had posted to do with it again, but it got taken down for duplication (when it wasn't duplication). This post is more of my observations from experimenting and such. There are distros I haven't tried but I tried a wide range of distros like manjaro, ubuntu based ones and all solus flavors, and I was looking more for how well they worked out of the box, rather than with fiddling around with pulse, hdajack etc which I know will work eventually. If you stumbled across this from searching about the same issue I have (or similar) or are confused to what this is about, I suggest you look at my previous post also.

      \n\n

      So anyways, I've tried the past few days mounting isos to usb drives and trying live os and installing various distros to see about the headphone issue. And my conclusion is that this issue affects the linux kernel in some way across the board. I don't really understand why completely but I have some kind of idea.

      \n\n

      From installing fresh distros, I noticed that the earphones (they are 3.5mm earphones + mic) get recognised as a microphone and not as a speaker system of some kind. Every single time I had a look at the sound settings and in pulse, they came up as plugged microphone, with the internal speakers being the only output device every single time. It's really odd seeing as how ubuntu 14.04 and xubuntu etc from years past worked flawlessly with the earphones, even manjaro a while ago on my older craptop worked fine. I don't really understand why it doesn't work on my device now.

      \n\n

      I'll leave my specs at the bottom of this post but what I think is is there's something the manufacturer did, or something like the cpu causes issue with linux. The manufacturer of my laptop is Lenovo, and the cpu/igpu is from AMD. A warning sign is that when installing a linux distro, it doesn't bring up the dual boot menu at startup like it should. Instead it completely hides the fact it exists until I use something like easyuefi to add an option for that distro, how it works is you specify the boot partition, whether it's linux or windows and the loader conf file for the distro. All of this hassle everytime doesn't appear on my craptop, because the dual boot menu appears flawlessly without issue. May be because it uses an Intel cpu/igpu unlike my newer laptop but it's hard to say.

      \n\n

      Also, it seems like the devices that appear in a given distro when looking at alsa, is hd generic devices but by reloading alsa or any command that shows the full name of the device, it says it's Intel. I don't know if that would be an issue, maybe amd use intel sound drivers or something. It's odd nonetheless.

      \n\n

      This issue has been boggling my mind for obvious reasons, with half-rhetorical questions like does linux not support the earphones anymore, whether out of accident from an overlooked bug in an update or intentionally phasing out? Is any of this AMD or Lenovo's fault? Even with proper headphones or something, will they fail? I don't think anyone here really knows, hell I'd bet an extreme that no one really understands why in the linux community. I kinda rambled in this post with stuff that should've been said in the last post/thread, but I'm saying it now.

      \n\n

      Thanks for contributing thus far to this discussion in figuring this out.

      \n\n

      Specs: AMD Ryzen 5 3500U Mobile CPU (2.2 - 3.7ghz quad core)

      \n\n

      Radeon Vega 8 Integrated GPU, 8GB Ram, 256GB SSD.

      \n\n

      Lenovo C340-14API Laptop

      \n
      ", + "author": "BrianMeerkatlol", + "publication_date": "2020-07-21T21:02:19Z", + "url": "https://www.reddit.com/r/linux/comments/hvdi3o/observations_on_a_linux_issue_with_35mm_earphones/", + "read": false, + "rule": 80, + "remote_identifier": "hvdi3o" + } +}, +{ + "model": "core.post", + "pk": 3170, + "fields": { + "created": "2020-07-21T20:14:50.509Z", + "modified": "2020-07-21T20:14:50.549Z", + "title": "South Korean distro HamoniKR OS has been added to Distrowatch", + "body": "", + "author": "TheHordeRisesAgain", + "publication_date": "2020-07-21T07:44:21Z", + "url": "https://www.reddit.com/r/linux/comments/hv1ug1/south_korean_distro_hamonikr_os_has_been_added_to/", + "read": false, + "rule": 80, + "remote_identifier": "hv1ug1" + } +}, +{ + "model": "core.post", + "pk": 3171, + "fields": { + "created": "2020-07-21T20:14:50.509Z", + "modified": "2020-07-21T20:14:50.559Z", + "title": "The Jailer is free! New release of the outstanding database subsetter and browser is available.", + "body": "", + "author": "Plane-Discussion", + "publication_date": "2020-07-21T12:53:54Z", + "url": "https://www.reddit.com/r/linux/comments/hv5b0j/the_jailer_is_free_new_release_of_the_outstanding/", + "read": false, + "rule": 80, + "remote_identifier": "hv5b0j" + } +}, +{ + "model": "core.post", + "pk": 3172, + "fields": { + "created": "2020-07-21T20:14:50.513Z", + "modified": "2020-07-21T20:14:50.563Z", + "title": "A few very well-aged excerpts from Microsoft\u2019s infamous 2004 \u201cGet the facts\u201d campaign, where they make the case for Windows servers being cheaper, more secure, and more performant than Linux servers", + "body": "
      \n

      Get the facts on Windows and Linux.

      \n\n

      Leading companies and third-party analysts confirm it: Windows has a lower total cost of ownership and outperforms Linux.

      \n\n

      ...

      \n\n

      -Security

      \n\n

      Windows Users Have Fewer Vulnerabilities

      \n
      \n\n

      And then literally the very next bullet point:

      \n\n
      \n

      -Featured Customer Case Study

      \n\n

      Equifax

      \n\n

      Equifax Sees 14 Percent Cost Savings

      \n\n

      Find out why Equifax, a global leader in transforming data into intelligence, selected Windows over Linux to enhance the speed and performance of its marketing services capabilities. Using Microsoft Windows Server System, the company has seen 14 percent in cost savings over Linux.

      \n
      \n\n

      Good thing they saved 14% and got all that extra security! Sure their website is janky and their login flow is downright horrifying (Check it out if you want to be amazed), but who could blame them? Linux is \u201cProhibitively Expensive, Extremely Complex, and Provides No Tangible Business Gains\u201d, Microsoft said so!

      \n\n

      Source: https://web.archive.org/web/20041027003759/http://www.microsoft.com/windowsserversystem/facts/default.mspx

      \n
      ", + "author": "kevinhaze", + "publication_date": "2020-07-20T21:42:15Z", + "url": "https://www.reddit.com/r/linux/comments/hus5lz/a_few_very_wellaged_excerpts_from_microsofts/", + "read": false, + "rule": 80, + "remote_identifier": "hus5lz" + } +}, +{ + "model": "core.post", + "pk": 3173, + "fields": { + "created": "2020-07-21T20:14:50.515Z", + "modified": "2020-07-21T20:14:50.566Z", + "title": "Are there are any professional audio recording studios or artists that use Linux?", + "body": "

      As the title says, who is using Linux as a professional audio engineer, producer, or artist? I am a former Mac user myself, and I am seeing people from time to time who have become disillusioned with what Apple has been doing for the past few years. However, I'm not sure if Linux really has a place for these people to land if they are serious about what they do.

      \n\n

      Fedora Design Suite and Ubuntu Studio are definitely encouraging to see, but what is their real-world usage like? Are we getting better with professional audio in Linux, or have things been stagnant for years?

      \n
      ", + "author": "RootHouston", + "publication_date": "2020-07-21T00:08:26Z", + "url": "https://www.reddit.com/r/linux/comments/huuxvq/are_there_are_any_professional_audio_recording/", + "read": false, + "rule": 80, + "remote_identifier": "huuxvq" + } +}, +{ + "model": "core.post", + "pk": 3174, + "fields": { + "created": "2020-07-21T20:14:50.515Z", + "modified": "2020-07-21T20:14:50.570Z", + "title": "When Linux had marketing", + "body": "", + "author": "Commodore256", + "publication_date": "2020-07-21T14:03:56Z", + "url": "https://www.reddit.com/r/linux/comments/hv65oa/when_linux_had_marketing/", + "read": false, + "rule": 80, + "remote_identifier": "hv65oa" + } +}, +{ + "model": "core.post", + "pk": 3175, + "fields": { + "created": "2020-07-21T20:14:50.520Z", + "modified": "2020-07-21T20:14:50.598Z", + "title": "Ward: Simple and minimalistic server dashboard", + "body": "

      Ward is a simple and and minimalistic server monitoring tool. Ward supports adaptive design system. Also it supports dark theme. It shows only principal information and can be used, if you want to see nice looking dashboard instead looking on bunch of numbers and graphs. Ward works nice on all popular operating systems, because it uses OSHI.

      \n\n

      https://preview.redd.it/gdppswc3a3c51.png?width=1448&format=png&auto=webp&s=0d6e10146c105ddcfd045dd59c970d4c127ddb8c

      \n\n

      https://github.com/B-Software/Ward

      \n
      ", + "author": "Pabyzu", + "publication_date": "2020-07-21T00:33:40Z", + "url": "https://www.reddit.com/r/linux/comments/huvea3/ward_simple_and_minimalistic_server_dashboard/", + "read": false, + "rule": 80, + "remote_identifier": "huvea3" + } +}, +{ + "model": "core.post", + "pk": 3176, + "fields": { + "created": "2020-07-21T20:14:50.522Z", + "modified": "2020-07-21T20:14:50.606Z", + "title": "WindowsFX - a good Windows alternative?", + "body": "

      I would personally like to hear some of your opinions (in the replies) about WindowsFX. What is WindowsFX you may ask? WindowsFX is a Brazilian linux distribution that is designed to look and act like Windows 10.

      \n\n

      Linux / WindowsFX is based off of Ubuntu, and uses Cinnamon as its DE. Upon first boot, normal Windows users can tell the difference. But if you were to put it in front of a non tech-savvy person, they wouldn't be able to tell the difference.

      \n\n

      Personally, with WSL on Windows, I see no need for a distro like this. However, as I said, I would like to hear your opinions on this distro.

      \n\n

      Video review here.

      \n
      ", + "author": "Demonitized101", + "publication_date": "2020-07-20T23:03:29Z", + "url": "https://www.reddit.com/r/linux/comments/hutpt5/windowsfx_a_good_windows_alternative/", + "read": false, + "rule": 80, + "remote_identifier": "hutpt5" + } +}, +{ + "model": "core.post", + "pk": 3177, + "fields": { + "created": "2020-07-21T20:14:50.775Z", + "modified": "2020-07-21T20:14:50.780Z", + "title": "Every day this good boy brings a carrot to his best buddy", + "body": "
      ", + "author": "TooShiftyForYou", + "publication_date": "2020-07-21T15:25:31Z", + "url": "https://www.reddit.com/r/aww/comments/hv7a8b/every_day_this_good_boy_brings_a_carrot_to_his/", + "read": false, + "rule": 81, + "remote_identifier": "hv7a8b" + } +}, +{ + "model": "core.post", + "pk": 3178, + "fields": { + "created": "2020-07-21T20:14:50.775Z", + "modified": "2020-07-25T20:08:34.264Z", + "title": "Kitten mimics his human petting the dog", + "body": "
      ", + "author": "SpecterAscendant", + "publication_date": "2020-07-21T14:56:57Z", + "url": "https://www.reddit.com/r/aww/comments/hv6ve3/kitten_mimics_his_human_petting_the_dog/", + "read": true, + "rule": 81, + "remote_identifier": "hv6ve3" + } +}, +{ + "model": "core.post", + "pk": 3179, + "fields": { + "created": "2020-07-21T20:14:50.775Z", + "modified": "2020-07-21T20:14:50.789Z", + "title": "My fox friend!", + "body": "
      ", + "author": "Zepantha", + "publication_date": "2020-07-21T14:27:25Z", + "url": "https://www.reddit.com/r/aww/comments/hv6gte/my_fox_friend/", + "read": false, + "rule": 81, + "remote_identifier": "hv6gte" + } +}, +{ + "model": "core.post", + "pk": 3180, + "fields": { + "created": "2020-07-21T20:14:50.775Z", + "modified": "2020-07-21T20:15:46.876Z", + "title": "Ducks annihilate peas", + "body": "
      ", + "author": "tommycalibre", + "publication_date": "2020-07-21T17:12:40Z", + "url": "https://www.reddit.com/r/aww/comments/hv9258/ducks_annihilate_peas/", + "read": true, + "rule": 81, + "remote_identifier": "hv9258" + } +}, +{ + "model": "core.post", + "pk": 3181, + "fields": { + "created": "2020-07-21T20:14:50.775Z", + "modified": "2020-07-21T20:14:50.797Z", + "title": "Wiggle it baby", + "body": "
      ", + "author": "neo_star", + "publication_date": "2020-07-21T18:44:31Z", + "url": "https://www.reddit.com/r/aww/comments/hvaucy/wiggle_it_baby/", + "read": false, + "rule": 81, + "remote_identifier": "hvaucy" + } +}, +{ + "model": "core.post", + "pk": 3182, + "fields": { + "created": "2020-07-21T20:14:50.776Z", + "modified": "2020-07-21T20:16:22.725Z", + "title": "I guess I should do this.. everyone seems to be liking little pups and kittens so.. Reddit, meet bailey", + "body": "
      \"I
      ", + "author": "X_XNOTHINGX_X", + "publication_date": "2020-07-21T14:15:08Z", + "url": "https://www.reddit.com/r/aww/comments/hv6b0a/i_guess_i_should_do_this_everyone_seems_to_be/", + "read": true, + "rule": 81, + "remote_identifier": "hv6b0a" + } +}, +{ + "model": "core.post", + "pk": 3183, + "fields": { + "created": "2020-07-21T20:14:50.776Z", + "modified": "2020-07-21T20:14:50.806Z", + "title": "The hat makes the crab.", + "body": "
      \"The
      ", + "author": "fujfuj", + "publication_date": "2020-07-21T14:48:40Z", + "url": "https://www.reddit.com/r/aww/comments/hv6rde/the_hat_makes_the_crab/", + "read": false, + "rule": 81, + "remote_identifier": "hv6rde" + } +}, +{ + "model": "core.post", + "pk": 3184, + "fields": { + "created": "2020-07-21T20:14:50.776Z", + "modified": "2020-07-21T20:14:50.812Z", + "title": "Baby bunny fits in hand", + "body": "
      ", + "author": "Hawken10", + "publication_date": "2020-07-21T12:31:30Z", + "url": "https://www.reddit.com/r/aww/comments/hv5253/baby_bunny_fits_in_hand/", + "read": false, + "rule": 81, + "remote_identifier": "hv5253" + } +}, +{ + "model": "core.post", + "pk": 3185, + "fields": { + "created": "2020-07-21T20:14:50.776Z", + "modified": "2020-07-21T20:14:50.818Z", + "title": "My cat and I, both pregnant", + "body": "
      \"My
      ", + "author": "nixdionisio", + "publication_date": "2020-07-21T11:06:25Z", + "url": "https://www.reddit.com/r/aww/comments/hv44m2/my_cat_and_i_both_pregnant/", + "read": false, + "rule": 81, + "remote_identifier": "hv44m2" + } +}, +{ + "model": "core.post", + "pk": 3186, + "fields": { + "created": "2020-07-21T20:14:50.776Z", + "modified": "2020-07-21T20:14:50.822Z", + "title": "Very sweet dance", + "body": "
      ", + "author": "Ashley1023", + "publication_date": "2020-07-21T13:03:03Z", + "url": "https://www.reddit.com/r/aww/comments/hv5ewq/very_sweet_dance/", + "read": false, + "rule": 81, + "remote_identifier": "hv5ewq" + } +}, +{ + "model": "core.post", + "pk": 3187, + "fields": { + "created": "2020-07-21T20:14:50.776Z", + "modified": "2020-07-21T20:14:50.825Z", + "title": "My local pet-store has a cat named Vegemite \u2764\ufe0f", + "body": "
      \"My
      ", + "author": "galinhad", + "publication_date": "2020-07-21T12:06:17Z", + "url": "https://www.reddit.com/r/aww/comments/hv4s5z/my_local_petstore_has_a_cat_named_vegemite/", + "read": false, + "rule": 81, + "remote_identifier": "hv4s5z" + } +}, +{ + "model": "core.post", + "pk": 3188, + "fields": { + "created": "2020-07-21T20:14:50.777Z", + "modified": "2020-07-21T20:15:01.459Z", + "title": "A teacher like that makes a huge difference", + "body": "
      ", + "author": "Unicornglitteryblood", + "publication_date": "2020-07-21T18:29:57Z", + "url": "https://www.reddit.com/r/aww/comments/hvajo9/a_teacher_like_that_makes_a_huge_difference/", + "read": true, + "rule": 81, + "remote_identifier": "hvajo9" + } +}, +{ + "model": "core.post", + "pk": 3189, + "fields": { + "created": "2020-07-21T20:14:50.777Z", + "modified": "2020-07-22T19:55:49.930Z", + "title": "Kitten Encounters Bubbly Water", + "body": "
      \"Kitten
      ", + "author": "DragonOBunny", + "publication_date": "2020-07-21T15:28:05Z", + "url": "https://www.reddit.com/r/aww/comments/hv7bis/kitten_encounters_bubbly_water/", + "read": true, + "rule": 81, + "remote_identifier": "hv7bis" + } +}, +{ + "model": "core.post", + "pk": 3190, + "fields": { + "created": "2020-07-21T20:14:50.777Z", + "modified": "2020-07-21T20:14:50.833Z", + "title": "Are These My Chickens Now?", + "body": "", + "author": "jasontaken", + "publication_date": "2020-07-21T09:55:36Z", + "url": "https://www.reddit.com/r/aww/comments/hv3de1/are_these_my_chickens_now/", + "read": false, + "rule": 81, + "remote_identifier": "hv3de1" + } +}, +{ + "model": "core.post", + "pk": 3191, + "fields": { + "created": "2020-07-21T20:14:50.777Z", + "modified": "2020-07-25T20:08:20.518Z", + "title": "Our St Bernard 6 months apart", + "body": "
      \"Our
      ", + "author": "ryan3105", + "publication_date": "2020-07-21T18:00:04Z", + "url": "https://www.reddit.com/r/aww/comments/hv9yea/our_st_bernard_6_months_apart/", + "read": true, + "rule": 81, + "remote_identifier": "hv9yea" + } +}, +{ + "model": "core.post", + "pk": 3192, + "fields": { + "created": "2020-07-21T20:14:50.777Z", + "modified": "2020-07-21T20:14:50.837Z", + "title": "Father and child in sync", + "body": "
      ", + "author": "Araragi_Monogatari", + "publication_date": "2020-07-21T08:29:18Z", + "url": "https://www.reddit.com/r/aww/comments/hv2enj/father_and_child_in_sync/", + "read": false, + "rule": 81, + "remote_identifier": "hv2enj" + } +}, +{ + "model": "core.post", + "pk": 3193, + "fields": { + "created": "2020-07-21T20:14:50.778Z", + "modified": "2020-07-21T20:14:50.840Z", + "title": "A meme is born", + "body": "
      \"A
      ", + "author": "Unicornglitteryblood", + "publication_date": "2020-07-21T18:55:04Z", + "url": "https://www.reddit.com/r/aww/comments/hvb1vh/a_meme_is_born/", + "read": false, + "rule": 81, + "remote_identifier": "hvb1vh" + } +}, +{ + "model": "core.post", + "pk": 3194, + "fields": { + "created": "2020-07-21T20:14:50.778Z", + "modified": "2020-07-21T20:14:50.842Z", + "title": "She bites, then she sleeps, then bites again, then sleeps again. \ud83d\ude02", + "body": "
      ", + "author": "earlymauvs", + "publication_date": "2020-07-21T11:34:19Z", + "url": "https://www.reddit.com/r/aww/comments/hv4fat/she_bites_then_she_sleeps_then_bites_again_then/", + "read": false, + "rule": 81, + "remote_identifier": "hv4fat" + } +}, +{ + "model": "core.post", + "pk": 3195, + "fields": { + "created": "2020-07-21T20:14:50.778Z", + "modified": "2020-07-21T20:14:50.844Z", + "title": "Nothing calmer that 2 ginger cats rubbing heads and showing their love in morning", + "body": "
      \"Nothing
      ", + "author": "Apotheosis33", + "publication_date": "2020-07-21T08:39:24Z", + "url": "https://www.reddit.com/r/aww/comments/hv2j2g/nothing_calmer_that_2_ginger_cats_rubbing_heads/", + "read": false, + "rule": 81, + "remote_identifier": "hv2j2g" + } +}, +{ + "model": "core.post", + "pk": 3196, + "fields": { + "created": "2020-07-21T20:14:50.778Z", + "modified": "2020-07-21T20:14:50.851Z", + "title": "Ring Tailed Possum", + "body": "", + "author": "Wayward-Delver", + "publication_date": "2020-07-21T11:23:51Z", + "url": "https://www.reddit.com/r/aww/comments/hv4b9e/ring_tailed_possum/", + "read": false, + "rule": 81, + "remote_identifier": "hv4b9e" + } +}, +{ + "model": "core.post", + "pk": 3197, + "fields": { + "created": "2020-07-21T20:14:50.778Z", + "modified": "2020-07-21T20:14:50.854Z", + "title": "Baby scooby in sad mood....", + "body": "
      \"Baby
      ", + "author": "deepanshuahiroo7", + "publication_date": "2020-07-21T15:12:23Z", + "url": "https://www.reddit.com/r/aww/comments/hv73ft/baby_scooby_in_sad_mood/", + "read": false, + "rule": 81, + "remote_identifier": "hv73ft" + } +}, +{ + "model": "core.post", + "pk": 3198, + "fields": { + "created": "2020-07-21T20:14:50.779Z", + "modified": "2020-07-21T20:14:50.856Z", + "title": "New friends!", + "body": "
      \"New
      ", + "author": "HelentotheKeller", + "publication_date": "2020-07-21T13:10:48Z", + "url": "https://www.reddit.com/r/aww/comments/hv5i6i/new_friends/", + "read": false, + "rule": 81, + "remote_identifier": "hv5i6i" + } +}, +{ + "model": "core.post", + "pk": 3199, + "fields": { + "created": "2020-07-21T20:14:50.779Z", + "modified": "2020-07-21T20:14:50.858Z", + "title": "When you haven't chewed anything for 1 second", + "body": "
      \"When
      ", + "author": "Tanay4", + "publication_date": "2020-07-21T10:26:53Z", + "url": "https://www.reddit.com/r/aww/comments/hv3pl0/when_you_havent_chewed_anything_for_1_second/", + "read": false, + "rule": 81, + "remote_identifier": "hv3pl0" + } +}, +{ + "model": "core.post", + "pk": 3200, + "fields": { + "created": "2020-07-21T20:14:50.779Z", + "modified": "2020-07-21T20:17:01.490Z", + "title": "Mango Derp", + "body": "
      \"Mango
      ", + "author": "sheetglass", + "publication_date": "2020-07-21T13:27:26Z", + "url": "https://www.reddit.com/r/aww/comments/hv5p8s/mango_derp/", + "read": true, + "rule": 81, + "remote_identifier": "hv5p8s" + } +}, +{ + "model": "core.post", + "pk": 3201, + "fields": { + "created": "2020-07-21T20:14:50.779Z", + "modified": "2020-07-21T20:14:50.863Z", + "title": "My guy turns 20 next month", + "body": "
      \"My
      ", + "author": "alozsoc", + "publication_date": "2020-07-21T06:34:26Z", + "url": "https://www.reddit.com/r/aww/comments/hv0xp1/my_guy_turns_20_next_month/", + "read": false, + "rule": 81, + "remote_identifier": "hv0xp1" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add log entry", + "content_type": [ + "admin", + "logentry" + ], + "codename": "add_logentry" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change log entry", + "content_type": [ + "admin", + "logentry" + ], + "codename": "change_logentry" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete log entry", + "content_type": [ + "admin", + "logentry" + ], + "codename": "delete_logentry" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view log entry", + "content_type": [ + "admin", + "logentry" + ], + "codename": "view_logentry" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add permission", + "content_type": [ + "auth", + "permission" + ], + "codename": "add_permission" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change permission", + "content_type": [ + "auth", + "permission" + ], + "codename": "change_permission" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete permission", + "content_type": [ + "auth", + "permission" + ], + "codename": "delete_permission" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view permission", + "content_type": [ + "auth", + "permission" + ], + "codename": "view_permission" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add group", + "content_type": [ + "auth", + "group" + ], + "codename": "add_group" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change group", + "content_type": [ + "auth", + "group" + ], + "codename": "change_group" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete group", + "content_type": [ + "auth", + "group" + ], + "codename": "delete_group" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view group", + "content_type": [ + "auth", + "group" + ], + "codename": "view_group" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add content type", + "content_type": [ + "contenttypes", + "contenttype" + ], + "codename": "add_contenttype" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change content type", + "content_type": [ + "contenttypes", + "contenttype" + ], + "codename": "change_contenttype" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete content type", + "content_type": [ + "contenttypes", + "contenttype" + ], + "codename": "delete_contenttype" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view content type", + "content_type": [ + "contenttypes", + "contenttype" + ], + "codename": "view_contenttype" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add session", + "content_type": [ + "sessions", + "session" + ], + "codename": "add_session" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change session", + "content_type": [ + "sessions", + "session" + ], + "codename": "change_session" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete session", + "content_type": [ + "sessions", + "session" + ], + "codename": "delete_session" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view session", + "content_type": [ + "sessions", + "session" + ], + "codename": "view_session" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add crontab", + "content_type": [ + "django_celery_beat", + "crontabschedule" + ], + "codename": "add_crontabschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change crontab", + "content_type": [ + "django_celery_beat", + "crontabschedule" + ], + "codename": "change_crontabschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete crontab", + "content_type": [ + "django_celery_beat", + "crontabschedule" + ], + "codename": "delete_crontabschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view crontab", + "content_type": [ + "django_celery_beat", + "crontabschedule" + ], + "codename": "view_crontabschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add interval", + "content_type": [ + "django_celery_beat", + "intervalschedule" + ], + "codename": "add_intervalschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change interval", + "content_type": [ + "django_celery_beat", + "intervalschedule" + ], + "codename": "change_intervalschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete interval", + "content_type": [ + "django_celery_beat", + "intervalschedule" + ], + "codename": "delete_intervalschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view interval", + "content_type": [ + "django_celery_beat", + "intervalschedule" + ], + "codename": "view_intervalschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add periodic task", + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "codename": "add_periodictask" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change periodic task", + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "codename": "change_periodictask" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete periodic task", + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "codename": "delete_periodictask" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view periodic task", + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "codename": "view_periodictask" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add periodic tasks", + "content_type": [ + "django_celery_beat", + "periodictasks" + ], + "codename": "add_periodictasks" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change periodic tasks", + "content_type": [ + "django_celery_beat", + "periodictasks" + ], + "codename": "change_periodictasks" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete periodic tasks", + "content_type": [ + "django_celery_beat", + "periodictasks" + ], + "codename": "delete_periodictasks" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view periodic tasks", + "content_type": [ + "django_celery_beat", + "periodictasks" + ], + "codename": "view_periodictasks" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add solar event", + "content_type": [ + "django_celery_beat", + "solarschedule" + ], + "codename": "add_solarschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change solar event", + "content_type": [ + "django_celery_beat", + "solarschedule" + ], + "codename": "change_solarschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete solar event", + "content_type": [ + "django_celery_beat", + "solarschedule" + ], + "codename": "delete_solarschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view solar event", + "content_type": [ + "django_celery_beat", + "solarschedule" + ], + "codename": "view_solarschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add clocked", + "content_type": [ + "django_celery_beat", + "clockedschedule" + ], + "codename": "add_clockedschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change clocked", + "content_type": [ + "django_celery_beat", + "clockedschedule" + ], + "codename": "change_clockedschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete clocked", + "content_type": [ + "django_celery_beat", + "clockedschedule" + ], + "codename": "delete_clockedschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view clocked", + "content_type": [ + "django_celery_beat", + "clockedschedule" + ], + "codename": "view_clockedschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add registration profile", + "content_type": [ + "registration", + "registrationprofile" + ], + "codename": "add_registrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change registration profile", + "content_type": [ + "registration", + "registrationprofile" + ], + "codename": "change_registrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete registration profile", + "content_type": [ + "registration", + "registrationprofile" + ], + "codename": "delete_registrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view registration profile", + "content_type": [ + "registration", + "registrationprofile" + ], + "codename": "view_registrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add supervised registration profile", + "content_type": [ + "registration", + "supervisedregistrationprofile" + ], + "codename": "add_supervisedregistrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change supervised registration profile", + "content_type": [ + "registration", + "supervisedregistrationprofile" + ], + "codename": "change_supervisedregistrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete supervised registration profile", + "content_type": [ + "registration", + "supervisedregistrationprofile" + ], + "codename": "delete_supervisedregistrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view supervised registration profile", + "content_type": [ + "registration", + "supervisedregistrationprofile" + ], + "codename": "view_supervisedregistrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add access attempt", + "content_type": [ + "axes", + "accessattempt" + ], + "codename": "add_accessattempt" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change access attempt", + "content_type": [ + "axes", + "accessattempt" + ], + "codename": "change_accessattempt" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete access attempt", + "content_type": [ + "axes", + "accessattempt" + ], + "codename": "delete_accessattempt" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view access attempt", + "content_type": [ + "axes", + "accessattempt" + ], + "codename": "view_accessattempt" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add access log", + "content_type": [ + "axes", + "accesslog" + ], + "codename": "add_accesslog" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change access log", + "content_type": [ + "axes", + "accesslog" + ], + "codename": "change_accesslog" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete access log", + "content_type": [ + "axes", + "accesslog" + ], + "codename": "delete_accesslog" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view access log", + "content_type": [ + "axes", + "accesslog" + ], + "codename": "view_accesslog" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add user", + "content_type": [ + "accounts", + "user" + ], + "codename": "add_user" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change user", + "content_type": [ + "accounts", + "user" + ], + "codename": "change_user" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete user", + "content_type": [ + "accounts", + "user" + ], + "codename": "delete_user" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view user", + "content_type": [ + "accounts", + "user" + ], + "codename": "view_user" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add post", + "content_type": [ + "core", + "post" + ], + "codename": "add_post" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change post", + "content_type": [ + "core", + "post" + ], + "codename": "change_post" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete post", + "content_type": [ + "core", + "post" + ], + "codename": "delete_post" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view post", + "content_type": [ + "core", + "post" + ], + "codename": "view_post" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add Category", + "content_type": [ + "core", + "category" + ], + "codename": "add_category" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change Category", + "content_type": [ + "core", + "category" + ], + "codename": "change_category" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete Category", + "content_type": [ + "core", + "category" + ], + "codename": "delete_category" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view Category", + "content_type": [ + "core", + "category" + ], + "codename": "view_category" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add collection rule", + "content_type": [ + "collection", + "collectionrule" + ], + "codename": "add_collectionrule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change collection rule", + "content_type": [ + "collection", + "collectionrule" + ], + "codename": "change_collectionrule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete collection rule", + "content_type": [ + "collection", + "collectionrule" + ], + "codename": "delete_collectionrule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view collection rule", + "content_type": [ + "collection", + "collectionrule" + ], + "codename": "view_collectionrule" + } +}, +{ + "model": "accounts.user", + "fields": { + "password": "pbkdf2_sha256$180000$U9a2CS9X0b8Y$T6bD/VoUOFoGNIp16aFlOL0N7q0e6A3I97ypm/AhsGo=", + "last_login": "2020-07-21T20:14:35.966Z", + "is_superuser": true, + "first_name": "", + "last_name": "", + "is_staff": true, + "is_active": true, + "date_joined": "2019-07-18T18:52:36.080Z", + "email": "sonny@bakker.nl", + "task": 10, + "reddit_refresh_token": null, + "reddit_access_token": null, + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "core.category", + "pk": 8, + "fields": { + "created": "2019-11-17T19:37:24.671Z", + "modified": "2019-11-18T19:59:55.010Z", + "name": "World news", + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "core.category", + "pk": 9, + "fields": { + "created": "2019-11-17T19:37:26.161Z", + "modified": "2020-05-30T13:36:10.509Z", + "name": "Tech", + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 3, + "fields": { + "created": "2019-07-14T13:08:10.374Z", + "modified": "2020-07-14T11:45:30.680Z", + "name": "Hackers News", + "type": "feed", + "url": "https://news.ycombinator.com/rss", + "website_url": "https://news.ycombinator.com/", + "favicon": "https://news.ycombinator.com/favicon.ico", + "timezone": "UTC", + "category": 9, + "last_suceeded": "2020-07-14T11:45:30.477Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 4, + "fields": { + "created": "2019-07-20T11:24:32.745Z", + "modified": "2020-07-14T11:45:29.357Z", + "name": "BBC", + "type": "feed", + "url": "http://feeds.bbci.co.uk/news/world/rss.xml", + "website_url": "https://www.bbc.co.uk/news/", + "favicon": "https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png", + "timezone": "UTC", + "category": 8, + "last_suceeded": "2020-07-14T11:45:28.863Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 5, + "fields": { + "created": "2019-07-20T11:24:50.411Z", + "modified": "2020-07-14T11:45:30.063Z", + "name": "Ars Technica", + "type": "feed", + "url": "http://feeds.arstechnica.com/arstechnica/index?fmt=xml", + "website_url": "https://arstechnica.com", + "favicon": "https://cdn.arstechnica.net/favicon.ico", + "timezone": "UTC", + "category": 9, + "last_suceeded": "2020-07-14T11:45:29.810Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 6, + "fields": { + "created": "2019-07-20T11:25:02.089Z", + "modified": "2020-07-14T11:45:30.473Z", + "name": "The Guardian", + "type": "feed", + "url": "https://www.theguardian.com/world/rss", + "website_url": "https://www.theguardian.com/world", + "favicon": "https://assets.guim.co.uk/images/favicons/873381bf11d58e20f551905d51575117/72x72.png", + "timezone": "UTC", + "category": 8, + "last_suceeded": "2020-07-14T11:45:30.181Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 7, + "fields": { + "created": "2019-07-20T11:25:30.121Z", + "modified": "2020-07-14T11:45:29.807Z", + "name": "Tweakers", + "type": "feed", + "url": "http://feeds.feedburner.com/tweakers/mixed?fmt=xml", + "website_url": "https://tweakers.net/", + "favicon": null, + "timezone": "UTC", + "category": 9, + "last_suceeded": "2020-07-14T11:45:29.525Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 8, + "fields": { + "created": "2019-07-20T11:25:46.256Z", + "modified": "2020-07-14T11:45:30.179Z", + "name": "The Verge", + "type": "feed", + "url": "https://www.theverge.com/rss/index.xml", + "website_url": "https://www.theverge.com/", + "favicon": "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395367/favicon-16x16.0.png", + "timezone": "UTC", + "category": 9, + "last_suceeded": "2020-07-14T11:45:30.066Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 9, + "fields": { + "created": "2019-11-24T15:28:41.399Z", + "modified": "2020-07-14T11:45:29.522Z", + "name": "NOS", + "type": "feed", + "url": "http://feeds.nos.nl/nosnieuwsalgemeen", + "website_url": null, + "favicon": null, + "timezone": "Europe/Amsterdam", + "category": 8, + "last_suceeded": "2020-07-14T11:45:29.362Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 80, + "fields": { + "created": "2020-07-08T19:30:10.638Z", + "modified": "2020-07-21T20:14:50.609Z", + "name": "Linux subreddit", + "type": "subreddit", + "url": "https://oauth.reddit.com/r/linux/hot", + "website_url": null, + "favicon": null, + "timezone": "UTC", + "category": 9, + "last_suceeded": "2020-07-21T20:14:50.492Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 81, + "fields": { + "created": "2020-07-08T19:30:33.590Z", + "modified": "2020-07-21T20:14:50.865Z", + "name": "AWW subreddit", + "type": "subreddit", + "url": "https://oauth.reddit.com/r/aww/hot", + "website_url": null, + "favicon": null, + "timezone": "UTC", + "category": 8, + "last_suceeded": "2020-07-21T20:14:50.768Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 82, + "fields": { + "created": "2020-07-20T19:29:37.675Z", + "modified": "2020-07-21T20:14:50.489Z", + "name": "Star citizen subreddit", + "type": "subreddit", + "url": "https://oauth.reddit.com/r/starcitizen/hot.json", + "website_url": null, + "favicon": null, + "timezone": "UTC", + "category": 9, + "last_suceeded": "2020-07-21T20:14:50.355Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "admin.logentry", + "pk": 1, + "fields": { + "action_time": "2020-05-24T18:38:44.624Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "django_celery_beat", + "intervalschedule" + ], + "object_id": "5", + "object_repr": "every 4 hours", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 2, + "fields": { + "action_time": "2020-05-24T18:38:46.689Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "object_id": "10", + "object_repr": "sonny@bakker.nl-collection-task: every 4 hours", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Interval Schedule\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 3, + "fields": { + "action_time": "2020-05-24T18:39:09.203Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "object_id": "26", + "object_repr": "sonnyba871@gmail.com-collection-task: every hour", + "action_flag": 3, + "change_message": "" + } +}, +{ + "model": "admin.logentry", + "pk": 4, + "fields": { + "action_time": "2020-05-24T19:46:50.248Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "object_id": "10", + "object_repr": "sonny@bakker.nl-collection-task: every 4 hours", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Positional Arguments\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 5, + "fields": { + "action_time": "2020-07-07T19:37:57.086Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "accounts", + "user" + ], + "object_id": "1", + "object_repr": "sonny@bakker.nl", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Reddit refresh token\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 6, + "fields": { + "action_time": "2020-07-07T19:39:46.160Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "object_id": "10", + "object_repr": "sonny@bakker.nl-collection-task: every 4 hours", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Task (registered)\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 7, + "fields": { + "action_time": "2020-07-08T19:29:27.025Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "object_id": "11", + "object_repr": "Reddit collection task: every 4 hours", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 8, + "fields": { + "action_time": "2020-07-14T11:46:50.039Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "accounts", + "user" + ], + "object_id": "1", + "object_repr": "sonny@bakker.nl", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Reddit access token\", \"Reddit refresh token\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 9, + "fields": { + "action_time": "2020-07-18T19:08:33.997Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "collection", + "collectionrule" + ], + "object_id": "81", + "object_repr": "AWW subreddit", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Url\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 10, + "fields": { + "action_time": "2020-07-18T19:08:44.063Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "collection", + "collectionrule" + ], + "object_id": "80", + "object_repr": "Linux subreddit", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Url\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 11, + "fields": { + "action_time": "2020-07-18T19:17:25.213Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "core", + "post" + ], + "object_id": "2336", + "object_repr": "Post-2336", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 12, + "fields": { + "action_time": "2020-07-18T19:17:40.596Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "core", + "post" + ], + "object_id": "2336", + "object_repr": "Post-2336", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 13, + "fields": { + "action_time": "2020-07-19T10:55:55.807Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "core", + "post" + ], + "object_id": "2764", + "object_repr": "Post-2764", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 14, + "fields": { + "action_time": "2020-07-19T10:57:40.643Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "core", + "post" + ], + "object_id": "2764", + "object_repr": "Post-2764", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 15, + "fields": { + "action_time": "2020-07-19T10:58:05.823Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "core", + "post" + ], + "object_id": "2764", + "object_repr": "Post-2764", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 16, + "fields": { + "action_time": "2020-07-26T09:51:52.478Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "accounts", + "user" + ], + "object_id": "1", + "object_repr": "sonny@bakker.nl", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"First name\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 17, + "fields": { + "action_time": "2020-07-26T09:52:04.691Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "accounts", + "user" + ], + "object_id": "1", + "object_repr": "sonny@bakker.nl", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"password\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 18, + "fields": { + "action_time": "2020-07-26T09:52:12.392Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "accounts", + "user" + ], + "object_id": "1", + "object_repr": "sonny@bakker.nl", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"First name\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 19, + "fields": { + "action_time": "2020-07-26T09:56:15.949Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "accounts", + "user" + ], + "object_id": "1", + "object_repr": "sonny@bakker.nl", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Reddit access token\", \"Reddit refresh token\"]}}]" + } +} +] diff --git a/src/newsreader/news/collection/forms.py b/src/newsreader/news/collection/forms.py index 604500d..c79a867 100644 --- a/src/newsreader/news/collection/forms.py +++ b/src/newsreader/news/collection/forms.py @@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _ import pytz +from newsreader.core.forms import CheckboxInput from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.models import CollectionRule from newsreader.news.collection.reddit import REDDIT_API_URL @@ -95,4 +96,6 @@ class SubRedditRuleForm(CollectionRuleForm): class OPMLImportForm(forms.Form): file = forms.FileField(allow_empty_file=False) - skip_existing = forms.BooleanField(initial=False, required=False) + skip_existing = forms.BooleanField( + initial=False, required=False, widget=CheckboxInput + ) diff --git a/src/newsreader/news/collection/templates/news/collection/views/rules.html b/src/newsreader/news/collection/templates/news/collection/views/rules.html index b8ab514..0cd1870 100644 --- a/src/newsreader/news/collection/templates/news/collection/views/rules.html +++ b/src/newsreader/news/collection/templates/news/collection/views/rules.html @@ -21,7 +21,7 @@
      -
      @@ -32,20 +32,27 @@ {% trans "URL" %} {% trans "Successfuly ran" %} {% trans "Enabled" %}
      {% with rule|id_for_label:"rules" as id_for_label %} {% include "components/form/checkbox.html" with name="rules" value=rule.pk id=id_for_label id_for_label=id_for_label %} {% endwith %} {{ rule.name }}{{ rule.category.name }}{{ rule.url }} + {{ rule.name }} + + {% if rule.category %} + {{ rule.category.name }} + {% endif %} + + {{ rule.url }} + {% if rule.succeeded %} @@ -60,9 +67,6 @@ {% endif %} - -
      +
      - {% for rule in rules %} - + - - - - - + + + + {% endfor %} diff --git a/src/newsreader/news/core/templates/news/core/widgets/rule.html b/src/newsreader/news/core/templates/news/core/widgets/rule.html index c8535e8..beebe29 100644 --- a/src/newsreader/news/core/templates/news/core/widgets/rule.html +++ b/src/newsreader/news/core/templates/news/core/widgets/rule.html @@ -1,7 +1,7 @@ {% load filters %} {% with option.instance|id_for_label:"category" as id_for_label %} - {% include "components/form/checkbox.html" with widget=option checked=option.selected id_for_label=id_for_label only %} + {% include "components/form/checkbox.html" with name=option.name value=option.value checked=option.selected id=id_for_label only %} {% endwith %} {% if option.instance.favicon %} diff --git a/src/newsreader/scss/components/card/_card.scss b/src/newsreader/scss/components/card/_card.scss index a9f957e..9866d4d 100644 --- a/src/newsreader/scss/components/card/_card.scss +++ b/src/newsreader/scss/components/card/_card.scss @@ -6,7 +6,6 @@ padding: 15px; width: 50%; - border-radius: 5px; background-color: $white; diff --git a/src/newsreader/scss/components/errorlist/_errorlist.scss b/src/newsreader/scss/components/errorlist/_errorlist.scss index 006dafb..6dbc458 100644 --- a/src/newsreader/scss/components/errorlist/_errorlist.scss +++ b/src/newsreader/scss/components/errorlist/_errorlist.scss @@ -14,7 +14,6 @@ padding: 10px; background-color: $error-red; - border-radius: 5px; } & li { diff --git a/src/newsreader/scss/components/form/_form.scss b/src/newsreader/scss/components/form/_form.scss index 79d3e43..089c4f1 100644 --- a/src/newsreader/scss/components/form/_form.scss +++ b/src/newsreader/scss/components/form/_form.scss @@ -5,7 +5,6 @@ flex-direction: column; width: 70%; - border-radius: 5px; background-color: $white; diff --git a/src/newsreader/scss/components/messages/_messages.scss b/src/newsreader/scss/components/messages/_messages.scss index 1d46932..b8ee6b5 100644 --- a/src/newsreader/scss/components/messages/_messages.scss +++ b/src/newsreader/scss/components/messages/_messages.scss @@ -17,8 +17,6 @@ padding: 20px 15px; margin: 5px 0; - border-radius: 5px; - background-color: $blue; &--error { diff --git a/src/newsreader/scss/components/modal/_post-modal.scss b/src/newsreader/scss/components/modal/_post-modal.scss index f357d77..f6483fe 100644 --- a/src/newsreader/scss/components/modal/_post-modal.scss +++ b/src/newsreader/scss/components/modal/_post-modal.scss @@ -4,6 +4,5 @@ margin: 0; padding: 0; - border-radius: 0; cursor: pointer; } diff --git a/src/newsreader/scss/components/post/_post.scss b/src/newsreader/scss/components/post/_post.scss index 6b41844..9374f39 100644 --- a/src/newsreader/scss/components/post/_post.scss +++ b/src/newsreader/scss/components/post/_post.scss @@ -72,6 +72,10 @@ padding: 10px 0; max-width: 100%; } + + & ul, ol { + list-style-position: inside; + } } &__close-button { diff --git a/src/newsreader/scss/components/section/_text-section.scss b/src/newsreader/scss/components/section/_text-section.scss index 9c5e8fc..bab9f6a 100644 --- a/src/newsreader/scss/components/section/_text-section.scss +++ b/src/newsreader/scss/components/section/_text-section.scss @@ -2,7 +2,6 @@ @extend .section; width: 70%; - border-radius: 5px; padding: 10px; diff --git a/src/newsreader/scss/components/sidebar/_sidebar.scss b/src/newsreader/scss/components/sidebar/_sidebar.scss index 89df180..f13faf3 100644 --- a/src/newsreader/scss/components/sidebar/_sidebar.scss +++ b/src/newsreader/scss/components/sidebar/_sidebar.scss @@ -15,7 +15,6 @@ overflow: auto; list-style: none; - border-radius: 5px; &__item { padding: 2px 10px 5px 10px; diff --git a/src/newsreader/scss/components/table/_rules-table.scss b/src/newsreader/scss/components/table/_rules-table.scss index 3eaf3b3..3be0430 100644 --- a/src/newsreader/scss/components/table/_rules-table.scss +++ b/src/newsreader/scss/components/table/_rules-table.scss @@ -5,7 +5,7 @@ } &--name { - width: 20%; + width: 25%; } &--category { @@ -23,16 +23,5 @@ &--enabled { width: 10%; } - - &--link { - width: 5%; - } - } - - & .link { - display: flex; - justify-content: center; - - padding: 10px; } } diff --git a/src/newsreader/scss/components/table/_table.scss b/src/newsreader/scss/components/table/_table.scss index 01f81a0..69bb298 100644 --- a/src/newsreader/scss/components/table/_table.scss +++ b/src/newsreader/scss/components/table/_table.scss @@ -2,6 +2,7 @@ .table { table-layout: fixed; + background-color: $white; width: 90%; padding: 20px; @@ -13,11 +14,15 @@ @extend .h1; } + &__row { + &--error { + background-color: transparentize($error-red, 0.8); + } + } + &__item { padding: 10px 0; - border-bottom: 1px solid $border-gray; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; diff --git a/src/newsreader/scss/pages/login/index.scss b/src/newsreader/scss/pages/login/index.scss index 82b9457..f1805ed 100644 --- a/src/newsreader/scss/pages/login/index.scss +++ b/src/newsreader/scss/pages/login/index.scss @@ -2,8 +2,6 @@ margin: 5% auto; width: 50%; - border-radius: 4px; - & .form { @extend .form; diff --git a/src/newsreader/templates/admin/base_site.html b/src/newsreader/templates/admin/base_site.html index c9d88b8..e29cef2 100644 --- a/src/newsreader/templates/admin/base_site.html +++ b/src/newsreader/templates/admin/base_site.html @@ -1,6 +1,10 @@ {% extends "admin/base.html" %} {% load static %} +{% block branding %} +

      Newsreader

      +{% endblock %} + {% block extrahead %} {% endblock %} diff --git a/src/newsreader/templates/components/form/checkbox.html b/src/newsreader/templates/components/form/checkbox.html index 42ac691..c36d5f6 100644 --- a/src/newsreader/templates/components/form/checkbox.html +++ b/src/newsreader/templates/components/form/checkbox.html @@ -1,10 +1,7 @@
      - {% if widget %} - {% include "components/form/input.html" with widget=widget %} - {% else %} - {% include "components/form/input.html" with id=id name=name type="checkbox" value=value data_input=data_input checked=checked %} - {% endif %} -
      diff --git a/src/newsreader/templates/components/form/input.html b/src/newsreader/templates/components/form/input.html index 08f32e1..1ecfaaf 100644 --- a/src/newsreader/templates/components/form/input.html +++ b/src/newsreader/templates/components/form/input.html @@ -3,5 +3,5 @@ type="{{ widget.type }}" name="{{ widget.name }}" {% if widget.value != None %} value="{{ widget.value|stringformat:'s' }}"{% endif %} {% endif %} - {% include "django/forms/widgets/attrs.html" %}> + {% include "components/form/attrs.html" %}> {% endspaceless %} diff --git a/src/newsreader/templates/django/forms/widgets/attrs.html b/src/newsreader/templates/django/forms/widgets/attrs.html deleted file mode 120000 index 595204e..0000000 --- a/src/newsreader/templates/django/forms/widgets/attrs.html +++ /dev/null @@ -1 +0,0 @@ -../../../components/form/attrs.html \ No newline at end of file diff --git a/src/newsreader/templates/django/forms/widgets/checkbox.html b/src/newsreader/templates/django/forms/widgets/checkbox.html deleted file mode 120000 index f939869..0000000 --- a/src/newsreader/templates/django/forms/widgets/checkbox.html +++ /dev/null @@ -1 +0,0 @@ -../../../components/form/checkbox.html \ No newline at end of file From 7d803bbfa0fe1f84cc54c92e251d9ed470eccb8b Mon Sep 17 00:00:00 2001 From: sonny Date: Mon, 27 Jul 2020 20:56:01 +0200 Subject: [PATCH 146/422] Show rule errors Fixes #56 --- .../news/collection/views/rule-update.html | 9 ++- .../collection/views/subreddit-update.html | 7 ++- .../news/collection/views/reddit.py | 1 + .../scss/components/card/_card.scss | 4 +- .../scss/components/category/_category.scss | 2 +- .../scss/components/errorlist/_errorlist.scss | 4 +- .../scss/components/form/_form.scss | 8 +-- .../scss/components/form/_mixin.scss | 3 - .../scss/components/messages/_messages.scss | 7 ++- .../components/pagination/_pagination.scss | 2 - .../scss/components/post/_post.scss | 6 +- .../scss/components/posts/_posts.scss | 2 +- .../scss/components/rules/_rules.scss | 4 +- .../components/section/_text-section.scss | 9 ++- .../scss/components/table/_table.scss | 4 +- .../scss/elements/button/_button.scss | 14 ++--- .../scss/elements/button/_mixins.scss | 3 - .../scss/elements/button/_read-button.scss | 4 +- .../scss/elements/input/_input.scss | 4 +- .../scss/elements/label/_label.scss | 2 +- src/newsreader/scss/lib/_mixins.scss | 10 ++++ src/newsreader/scss/lib/index.scss | 1 + src/newsreader/scss/partials/_colors.scss | 59 +++++++------------ .../templates/components/form/form.html | 5 +- .../templates/components/textbox/textbox.html | 13 ++++ 25 files changed, 100 insertions(+), 87 deletions(-) delete mode 100644 src/newsreader/scss/components/form/_mixin.scss delete mode 100644 src/newsreader/scss/elements/button/_mixins.scss create mode 100644 src/newsreader/templates/components/textbox/textbox.html diff --git a/src/newsreader/news/collection/templates/news/collection/views/rule-update.html b/src/newsreader/news/collection/templates/news/collection/views/rule-update.html index 3f0a8fe..0a705b8 100644 --- a/src/newsreader/news/collection/templates/news/collection/views/rule-update.html +++ b/src/newsreader/news/collection/templates/news/collection/views/rule-update.html @@ -1,9 +1,14 @@ {% extends "base.html" %} -{% load static %} +{% load static i18n %} {% block content %}
      + {% if rule.error %} + {% trans "Failed to retrieve posts" as title %} + {% include "components/textbox/textbox.html" with title=title body=rule.error class="text-section--error" only %} + {% endif %} + {% url "news:collection:rules" as cancel_url %} - {% include "components/form/form.html" with form=form title="Update rule" cancel_url=cancel_url confirm_text="Save rule" %} + {% include "components/form/form.html" with form=form title="Update rule" cancel_url=cancel_url confirm_text="Save rule" only %}
      {% endblock %} diff --git a/src/newsreader/news/collection/templates/news/collection/views/subreddit-update.html b/src/newsreader/news/collection/templates/news/collection/views/subreddit-update.html index 9ea7d05..0099e3b 100644 --- a/src/newsreader/news/collection/templates/news/collection/views/subreddit-update.html +++ b/src/newsreader/news/collection/templates/news/collection/views/subreddit-update.html @@ -1,8 +1,13 @@ {% extends "base.html" %} -{% load static %} +{% load static i18n %} {% block content %}
      + {% if subreddit.error %} + {% trans "Failed to retrieve posts" as title %} + {% include "components/textbox/textbox.html" with title=title body=subreddit.error class="text-section--error" only %} + {% endif %} + {% url "news:collection:rules" as cancel_url %} {% include "components/form/form.html" with form=form title="Update subreddit" cancel_url=cancel_url confirm_text="Save subreddit" %}
      diff --git a/src/newsreader/news/collection/views/reddit.py b/src/newsreader/news/collection/views/reddit.py index 533513b..62ec408 100644 --- a/src/newsreader/news/collection/views/reddit.py +++ b/src/newsreader/news/collection/views/reddit.py @@ -20,6 +20,7 @@ class SubRedditUpdateView( ): form_class = SubRedditRuleForm template_name = "news/collection/views/subreddit-update.html" + context_object_name = "subreddit" def get_queryset(self): queryset = super().get_queryset() diff --git a/src/newsreader/scss/components/card/_card.scss b/src/newsreader/scss/components/card/_card.scss index 9866d4d..b77522a 100644 --- a/src/newsreader/scss/components/card/_card.scss +++ b/src/newsreader/scss/components/card/_card.scss @@ -3,7 +3,7 @@ flex-direction: column; margin: 20px 0; - padding: 15px; + @include block-padding; width: 50%; @@ -16,7 +16,7 @@ padding: 15px 0; - border-bottom: 2px $border-gray solid; + border-bottom: 2px $gray solid; } &__content { diff --git a/src/newsreader/scss/components/category/_category.scss b/src/newsreader/scss/components/category/_category.scss index e8e1ba9..6710af2 100644 --- a/src/newsreader/scss/components/category/_category.scss +++ b/src/newsreader/scss/components/category/_category.scss @@ -36,6 +36,6 @@ } &--selected, &:hover { - background-color: $border-gray; + background-color: $gray; } } diff --git a/src/newsreader/scss/components/errorlist/_errorlist.scss b/src/newsreader/scss/components/errorlist/_errorlist.scss index 6dbc458..d9c592c 100644 --- a/src/newsreader/scss/components/errorlist/_errorlist.scss +++ b/src/newsreader/scss/components/errorlist/_errorlist.scss @@ -11,9 +11,9 @@ &__item { margin: 10px 0; - padding: 10px; + @include text-padding; - background-color: $error-red; + background-color: $transparant-red; } & li { diff --git a/src/newsreader/scss/components/form/_form.scss b/src/newsreader/scss/components/form/_form.scss index 089c4f1..9af3b12 100644 --- a/src/newsreader/scss/components/form/_form.scss +++ b/src/newsreader/scss/components/form/_form.scss @@ -1,5 +1,3 @@ -@import "mixin.scss"; - .form { display: flex; flex-direction: column; @@ -39,7 +37,7 @@ display: flex; flex-direction: row; - @include form-padding; + @include block-padding; } &__actions { @@ -47,7 +45,7 @@ flex-direction: row; gap: 15px; - @include form-padding; + @include block-padding; } &__title { @@ -55,7 +53,7 @@ } &__intro { - @include form-padding; + @include block-padding; } & .favicon { diff --git a/src/newsreader/scss/components/form/_mixin.scss b/src/newsreader/scss/components/form/_mixin.scss deleted file mode 100644 index 4f55a9e..0000000 --- a/src/newsreader/scss/components/form/_mixin.scss +++ /dev/null @@ -1,3 +0,0 @@ -@mixin form-padding { - padding: 15px; -} diff --git a/src/newsreader/scss/components/messages/_messages.scss b/src/newsreader/scss/components/messages/_messages.scss index b8ee6b5..74d88b5 100644 --- a/src/newsreader/scss/components/messages/_messages.scss +++ b/src/newsreader/scss/components/messages/_messages.scss @@ -20,15 +20,16 @@ background-color: $blue; &--error { - background-color: $error-red; + background-color: $transparant-red; } &--warning { - background-color: $light-orange; + background-color: $transparant-orange; } + // TODO check this color &--success { - background-color: $success-green; + background-color: $transparant-green; } & .gg-close { diff --git a/src/newsreader/scss/components/pagination/_pagination.scss b/src/newsreader/scss/components/pagination/_pagination.scss index d4ba4a9..31dca88 100644 --- a/src/newsreader/scss/components/pagination/_pagination.scss +++ b/src/newsreader/scss/components/pagination/_pagination.scss @@ -1,5 +1,3 @@ -@import "../../elements/button/mixins"; - .pagination { display: flex; justify-content: space-evenly; diff --git a/src/newsreader/scss/components/post/_post.scss b/src/newsreader/scss/components/post/_post.scss index 9374f39..e73dbd2 100644 --- a/src/newsreader/scss/components/post/_post.scss +++ b/src/newsreader/scss/components/post/_post.scss @@ -46,7 +46,7 @@ } &__rule, &__category { - background-color: $light-orange !important; + background-color: $orange !important; & a { color: $black; @@ -82,11 +82,11 @@ position: relative; margin: 1% 2% 0 0; align-self: flex-end; - background-color: $button-blue; + background-color: $blue; color: $white; &:hover { - background-color: lighten($button-blue, +1%); + background-color: lighten($blue, +1%); } } diff --git a/src/newsreader/scss/components/posts/_posts.scss b/src/newsreader/scss/components/posts/_posts.scss index 94223a6..8bb86e9 100644 --- a/src/newsreader/scss/components/posts/_posts.scss +++ b/src/newsreader/scss/components/posts/_posts.scss @@ -30,7 +30,7 @@ } & .badge { - background-color: $light-orange; + background-color: $orange; } &:last-child { diff --git a/src/newsreader/scss/components/rules/_rules.scss b/src/newsreader/scss/components/rules/_rules.scss index b07d03d..527d99a 100644 --- a/src/newsreader/scss/components/rules/_rules.scss +++ b/src/newsreader/scss/components/rules/_rules.scss @@ -14,11 +14,11 @@ &:hover { cursor: pointer; - background-color: $border-gray; + background-color: $gray; } &--selected { - background-color: $border-gray; + background-color: $gray; } } diff --git a/src/newsreader/scss/components/section/_text-section.scss b/src/newsreader/scss/components/section/_text-section.scss index bab9f6a..2efe0a4 100644 --- a/src/newsreader/scss/components/section/_text-section.scss +++ b/src/newsreader/scss/components/section/_text-section.scss @@ -1,9 +1,14 @@ .text-section { @extend .section; + @include block-padding; width: 70%; - padding: 10px; + &--error { + background-color: $transparant-red; + } - background-color: $white; + &__body { + @include text-padding; + } } diff --git a/src/newsreader/scss/components/table/_table.scss b/src/newsreader/scss/components/table/_table.scss index 69bb298..74d5d6e 100644 --- a/src/newsreader/scss/components/table/_table.scss +++ b/src/newsreader/scss/components/table/_table.scss @@ -1,5 +1,3 @@ -@import "../../lib/mixins"; - .table { table-layout: fixed; @@ -16,7 +14,7 @@ &__row { &--error { - background-color: transparentize($error-red, 0.8); + background-color: $transparant-red; } } diff --git a/src/newsreader/scss/elements/button/_button.scss b/src/newsreader/scss/elements/button/_button.scss index 50af49e..a8eb3bc 100644 --- a/src/newsreader/scss/elements/button/_button.scss +++ b/src/newsreader/scss/elements/button/_button.scss @@ -1,5 +1,3 @@ -@import "mixins"; - .button { display: flex; @@ -18,29 +16,29 @@ &--success, &--confirm { color: $white !important; - background-color: $confirm-green; + background-color: $green; &:hover { - background-color: lighten($confirm-green, +5%); + background-color: lighten($green, +5%); } } &--error, &--cancel { color: $white !important; - background-color: $error-red; + background-color: $red; &:hover { - background-color: lighten($error-red, +5%); + background-color: lighten($red, +5%); } } &--primary { color: $white !important; - background-color: $button-blue; + background-color: $blue; &:hover { - background-color: lighten($button-blue, 5%); + background-color: lighten($blue, 5%); } } diff --git a/src/newsreader/scss/elements/button/_mixins.scss b/src/newsreader/scss/elements/button/_mixins.scss deleted file mode 100644 index 75b70e3..0000000 --- a/src/newsreader/scss/elements/button/_mixins.scss +++ /dev/null @@ -1,3 +0,0 @@ -@mixin button-padding { - padding: 7px 40px; -} diff --git a/src/newsreader/scss/elements/button/_read-button.scss b/src/newsreader/scss/elements/button/_read-button.scss index 940d895..2c345b5 100644 --- a/src/newsreader/scss/elements/button/_read-button.scss +++ b/src/newsreader/scss/elements/button/_read-button.scss @@ -4,9 +4,9 @@ margin: 20px 0 0 0; color: $white; - background-color: $confirm-green; + background-color: $green; &:hover { - background-color: darken($confirm-green, 10%); + background-color: darken($green, 10%); } } diff --git a/src/newsreader/scss/elements/input/_input.scss b/src/newsreader/scss/elements/input/_input.scss index 8258020..16e6fad 100644 --- a/src/newsreader/scss/elements/input/_input.scss +++ b/src/newsreader/scss/elements/input/_input.scss @@ -1,7 +1,7 @@ .input { - padding: 10px; + @include text-padding; - border: 1px $border-gray solid; + border: 1px $gray solid; &:focus { border: 1px $focus-blue solid; diff --git a/src/newsreader/scss/elements/label/_label.scss b/src/newsreader/scss/elements/label/_label.scss index 5030a4c..6481b02 100644 --- a/src/newsreader/scss/elements/label/_label.scss +++ b/src/newsreader/scss/elements/label/_label.scss @@ -1,5 +1,5 @@ .label { - padding: 10px; + @include text-padding; } label { diff --git a/src/newsreader/scss/lib/_mixins.scss b/src/newsreader/scss/lib/_mixins.scss index 8b13789..72c9932 100644 --- a/src/newsreader/scss/lib/_mixins.scss +++ b/src/newsreader/scss/lib/_mixins.scss @@ -1 +1,11 @@ +@mixin text-padding { + padding: 10px; +} +@mixin block-padding { + padding: 15px; +} + +@mixin button-padding { + padding: 7px 40px; +} diff --git a/src/newsreader/scss/lib/index.scss b/src/newsreader/scss/lib/index.scss index ec6885e..026bf87 100644 --- a/src/newsreader/scss/lib/index.scss +++ b/src/newsreader/scss/lib/index.scss @@ -1 +1,2 @@ @import 'css.gg'; +@import 'mixins'; diff --git a/src/newsreader/scss/partials/_colors.scss b/src/newsreader/scss/partials/_colors.scss index aee33c2..b2f124d 100644 --- a/src/newsreader/scss/partials/_colors.scss +++ b/src/newsreader/scss/partials/_colors.scss @@ -1,44 +1,27 @@ -// light blue -$azureish-white: rgba(205, 230, 245, 1); - -// dark blue -$pewter-blue: rgba(141, 167, 190, 1); - -// light gray -$gainsboro: rgba(238, 238, 238, 1); - -// medium gray -$roman-silver: rgba(135, 145, 158, 1); - -//dark gray -$nickel: rgba(112, 112, 120, 1); - -$pink: rgba(235, 229, 229, 1); -$lavendal-pink: rgba(162, 155, 254, 1); - -$beige: rgba(245, 245, 220, 1); - -$light-green: rgba(230, 247, 185, 1); -$light-orange: rgba(255, 212, 153, 1); -$light-red: rgba(255, 118, 117, 1); - -$success-green: rgba(89, 181, 128, 1); -$error-red: lighten(rgba(231, 76, 60, 1), 10%); - -$confirm-green: $success-green; -$cancel-red: $error-red; - -$border-gray: rgba(227, 227, 227, 1); - -$button-blue: rgba(111, 164, 196, 1); -$focus-blue: darken($azureish-white, +10%); -$checkbox-blue: rgba(34, 170, 253, 1); -$font-color: rgba(48, 51, 53, 1); -$header-color: rgba(100, 101, 102, 1); +$orange: rgba(255, 212, 153, 1); +$green: rgba(89, 181, 128, 1); +$red: lighten(rgba(231, 76, 60, 1), 10%); +$gray: rgba(227, 227, 227, 1); +$blue: rgba(111, 164, 196, 1); $white: rgba(255, 255, 255, 1); $black: rgba(0, 0, 0, 1); -$blue: darken($azureish-white, +50%); $dark: rgba(0, 0, 0, 0.4); +$font-color: rgba(48, 51, 53, 1); +$header-color: rgba(100, 101, 102, 1); + $reddit-orange: rgba(255, 69, 0, 1); + +$transparant-red: transparentize($red, 0.8); +$transparant-blue: transparentize($blue, 0.8); +$transparant-orange: transparentize($orange, 0.4); +$transparant-green: transparentize($green, 0.4); + +$azureish-white: rgba(205, 230, 245, 1); +$gainsboro: rgba(238, 238, 238, 1); +$nickel: rgba(112, 112, 120, 1); +$lavendal-pink: rgba(162, 155, 254, 1); + +$focus-blue: darken($azureish-white, +10%); +$checkbox-blue: rgba(34, 170, 253, 1); diff --git a/src/newsreader/templates/components/form/form.html b/src/newsreader/templates/components/form/form.html index d854eb1..e183c25 100644 --- a/src/newsreader/templates/components/form/form.html +++ b/src/newsreader/templates/components/form/form.html @@ -26,7 +26,10 @@ {{ field.errors }} {{ field }} - {% include "components/form/help-text.html" %} + + {% if field.help_text %} + {% include "components/form/help-text.html" %} + {% endif %} {% endfor %} diff --git a/src/newsreader/templates/components/textbox/textbox.html b/src/newsreader/templates/components/textbox/textbox.html new file mode 100644 index 0000000..425cf60 --- /dev/null +++ b/src/newsreader/templates/components/textbox/textbox.html @@ -0,0 +1,13 @@ +
      + {% if title %} +

      {{ title }}

      + {% endif %} + + {% if body %} +

      {{ body }}

      + {% endif %} + + {% if footer %} + + {% endif %} +
      From 7dab98ef5acbbc9d75947e51ea706aeba5cf6ed5 Mon Sep 17 00:00:00 2001 From: sonny Date: Mon, 27 Jul 2020 21:10:35 +0200 Subject: [PATCH 147/422] Show error info in rule detail pages --- .../news/collection/views/rule-update.html | 9 ++- .../collection/views/subreddit-update.html | 7 ++- .../news/collection/views/reddit.py | 1 + .../scss/components/card/_card.scss | 4 +- .../scss/components/category/_category.scss | 2 +- .../scss/components/errorlist/_errorlist.scss | 4 +- .../scss/components/form/_form.scss | 8 +-- .../scss/components/form/_mixin.scss | 3 - .../scss/components/messages/_messages.scss | 7 ++- .../components/pagination/_pagination.scss | 2 - .../scss/components/post/_post.scss | 6 +- .../scss/components/posts/_posts.scss | 2 +- .../scss/components/rules/_rules.scss | 4 +- .../components/section/_text-section.scss | 9 ++- .../scss/components/table/_table.scss | 4 +- .../scss/elements/button/_button.scss | 14 ++--- .../scss/elements/button/_mixins.scss | 3 - .../scss/elements/button/_read-button.scss | 4 +- .../scss/elements/input/_input.scss | 4 +- .../scss/elements/label/_label.scss | 2 +- src/newsreader/scss/lib/_mixins.scss | 10 ++++ src/newsreader/scss/lib/index.scss | 1 + src/newsreader/scss/partials/_colors.scss | 59 +++++++------------ .../templates/components/form/form.html | 5 +- .../templates/components/textbox/textbox.html | 13 ++++ 25 files changed, 100 insertions(+), 87 deletions(-) delete mode 100644 src/newsreader/scss/components/form/_mixin.scss delete mode 100644 src/newsreader/scss/elements/button/_mixins.scss create mode 100644 src/newsreader/templates/components/textbox/textbox.html diff --git a/src/newsreader/news/collection/templates/news/collection/views/rule-update.html b/src/newsreader/news/collection/templates/news/collection/views/rule-update.html index 3f0a8fe..0a705b8 100644 --- a/src/newsreader/news/collection/templates/news/collection/views/rule-update.html +++ b/src/newsreader/news/collection/templates/news/collection/views/rule-update.html @@ -1,9 +1,14 @@ {% extends "base.html" %} -{% load static %} +{% load static i18n %} {% block content %}
      + {% if rule.error %} + {% trans "Failed to retrieve posts" as title %} + {% include "components/textbox/textbox.html" with title=title body=rule.error class="text-section--error" only %} + {% endif %} + {% url "news:collection:rules" as cancel_url %} - {% include "components/form/form.html" with form=form title="Update rule" cancel_url=cancel_url confirm_text="Save rule" %} + {% include "components/form/form.html" with form=form title="Update rule" cancel_url=cancel_url confirm_text="Save rule" only %}
      {% endblock %} diff --git a/src/newsreader/news/collection/templates/news/collection/views/subreddit-update.html b/src/newsreader/news/collection/templates/news/collection/views/subreddit-update.html index 9ea7d05..0099e3b 100644 --- a/src/newsreader/news/collection/templates/news/collection/views/subreddit-update.html +++ b/src/newsreader/news/collection/templates/news/collection/views/subreddit-update.html @@ -1,8 +1,13 @@ {% extends "base.html" %} -{% load static %} +{% load static i18n %} {% block content %}
      + {% if subreddit.error %} + {% trans "Failed to retrieve posts" as title %} + {% include "components/textbox/textbox.html" with title=title body=subreddit.error class="text-section--error" only %} + {% endif %} + {% url "news:collection:rules" as cancel_url %} {% include "components/form/form.html" with form=form title="Update subreddit" cancel_url=cancel_url confirm_text="Save subreddit" %}
      diff --git a/src/newsreader/news/collection/views/reddit.py b/src/newsreader/news/collection/views/reddit.py index 533513b..62ec408 100644 --- a/src/newsreader/news/collection/views/reddit.py +++ b/src/newsreader/news/collection/views/reddit.py @@ -20,6 +20,7 @@ class SubRedditUpdateView( ): form_class = SubRedditRuleForm template_name = "news/collection/views/subreddit-update.html" + context_object_name = "subreddit" def get_queryset(self): queryset = super().get_queryset() diff --git a/src/newsreader/scss/components/card/_card.scss b/src/newsreader/scss/components/card/_card.scss index 9866d4d..b77522a 100644 --- a/src/newsreader/scss/components/card/_card.scss +++ b/src/newsreader/scss/components/card/_card.scss @@ -3,7 +3,7 @@ flex-direction: column; margin: 20px 0; - padding: 15px; + @include block-padding; width: 50%; @@ -16,7 +16,7 @@ padding: 15px 0; - border-bottom: 2px $border-gray solid; + border-bottom: 2px $gray solid; } &__content { diff --git a/src/newsreader/scss/components/category/_category.scss b/src/newsreader/scss/components/category/_category.scss index e8e1ba9..6710af2 100644 --- a/src/newsreader/scss/components/category/_category.scss +++ b/src/newsreader/scss/components/category/_category.scss @@ -36,6 +36,6 @@ } &--selected, &:hover { - background-color: $border-gray; + background-color: $gray; } } diff --git a/src/newsreader/scss/components/errorlist/_errorlist.scss b/src/newsreader/scss/components/errorlist/_errorlist.scss index 6dbc458..d9c592c 100644 --- a/src/newsreader/scss/components/errorlist/_errorlist.scss +++ b/src/newsreader/scss/components/errorlist/_errorlist.scss @@ -11,9 +11,9 @@ &__item { margin: 10px 0; - padding: 10px; + @include text-padding; - background-color: $error-red; + background-color: $transparant-red; } & li { diff --git a/src/newsreader/scss/components/form/_form.scss b/src/newsreader/scss/components/form/_form.scss index 089c4f1..9af3b12 100644 --- a/src/newsreader/scss/components/form/_form.scss +++ b/src/newsreader/scss/components/form/_form.scss @@ -1,5 +1,3 @@ -@import "mixin.scss"; - .form { display: flex; flex-direction: column; @@ -39,7 +37,7 @@ display: flex; flex-direction: row; - @include form-padding; + @include block-padding; } &__actions { @@ -47,7 +45,7 @@ flex-direction: row; gap: 15px; - @include form-padding; + @include block-padding; } &__title { @@ -55,7 +53,7 @@ } &__intro { - @include form-padding; + @include block-padding; } & .favicon { diff --git a/src/newsreader/scss/components/form/_mixin.scss b/src/newsreader/scss/components/form/_mixin.scss deleted file mode 100644 index 4f55a9e..0000000 --- a/src/newsreader/scss/components/form/_mixin.scss +++ /dev/null @@ -1,3 +0,0 @@ -@mixin form-padding { - padding: 15px; -} diff --git a/src/newsreader/scss/components/messages/_messages.scss b/src/newsreader/scss/components/messages/_messages.scss index b8ee6b5..74d88b5 100644 --- a/src/newsreader/scss/components/messages/_messages.scss +++ b/src/newsreader/scss/components/messages/_messages.scss @@ -20,15 +20,16 @@ background-color: $blue; &--error { - background-color: $error-red; + background-color: $transparant-red; } &--warning { - background-color: $light-orange; + background-color: $transparant-orange; } + // TODO check this color &--success { - background-color: $success-green; + background-color: $transparant-green; } & .gg-close { diff --git a/src/newsreader/scss/components/pagination/_pagination.scss b/src/newsreader/scss/components/pagination/_pagination.scss index d4ba4a9..31dca88 100644 --- a/src/newsreader/scss/components/pagination/_pagination.scss +++ b/src/newsreader/scss/components/pagination/_pagination.scss @@ -1,5 +1,3 @@ -@import "../../elements/button/mixins"; - .pagination { display: flex; justify-content: space-evenly; diff --git a/src/newsreader/scss/components/post/_post.scss b/src/newsreader/scss/components/post/_post.scss index 9374f39..e73dbd2 100644 --- a/src/newsreader/scss/components/post/_post.scss +++ b/src/newsreader/scss/components/post/_post.scss @@ -46,7 +46,7 @@ } &__rule, &__category { - background-color: $light-orange !important; + background-color: $orange !important; & a { color: $black; @@ -82,11 +82,11 @@ position: relative; margin: 1% 2% 0 0; align-self: flex-end; - background-color: $button-blue; + background-color: $blue; color: $white; &:hover { - background-color: lighten($button-blue, +1%); + background-color: lighten($blue, +1%); } } diff --git a/src/newsreader/scss/components/posts/_posts.scss b/src/newsreader/scss/components/posts/_posts.scss index 94223a6..8bb86e9 100644 --- a/src/newsreader/scss/components/posts/_posts.scss +++ b/src/newsreader/scss/components/posts/_posts.scss @@ -30,7 +30,7 @@ } & .badge { - background-color: $light-orange; + background-color: $orange; } &:last-child { diff --git a/src/newsreader/scss/components/rules/_rules.scss b/src/newsreader/scss/components/rules/_rules.scss index b07d03d..527d99a 100644 --- a/src/newsreader/scss/components/rules/_rules.scss +++ b/src/newsreader/scss/components/rules/_rules.scss @@ -14,11 +14,11 @@ &:hover { cursor: pointer; - background-color: $border-gray; + background-color: $gray; } &--selected { - background-color: $border-gray; + background-color: $gray; } } diff --git a/src/newsreader/scss/components/section/_text-section.scss b/src/newsreader/scss/components/section/_text-section.scss index bab9f6a..2efe0a4 100644 --- a/src/newsreader/scss/components/section/_text-section.scss +++ b/src/newsreader/scss/components/section/_text-section.scss @@ -1,9 +1,14 @@ .text-section { @extend .section; + @include block-padding; width: 70%; - padding: 10px; + &--error { + background-color: $transparant-red; + } - background-color: $white; + &__body { + @include text-padding; + } } diff --git a/src/newsreader/scss/components/table/_table.scss b/src/newsreader/scss/components/table/_table.scss index 69bb298..74d5d6e 100644 --- a/src/newsreader/scss/components/table/_table.scss +++ b/src/newsreader/scss/components/table/_table.scss @@ -1,5 +1,3 @@ -@import "../../lib/mixins"; - .table { table-layout: fixed; @@ -16,7 +14,7 @@ &__row { &--error { - background-color: transparentize($error-red, 0.8); + background-color: $transparant-red; } } diff --git a/src/newsreader/scss/elements/button/_button.scss b/src/newsreader/scss/elements/button/_button.scss index 50af49e..a8eb3bc 100644 --- a/src/newsreader/scss/elements/button/_button.scss +++ b/src/newsreader/scss/elements/button/_button.scss @@ -1,5 +1,3 @@ -@import "mixins"; - .button { display: flex; @@ -18,29 +16,29 @@ &--success, &--confirm { color: $white !important; - background-color: $confirm-green; + background-color: $green; &:hover { - background-color: lighten($confirm-green, +5%); + background-color: lighten($green, +5%); } } &--error, &--cancel { color: $white !important; - background-color: $error-red; + background-color: $red; &:hover { - background-color: lighten($error-red, +5%); + background-color: lighten($red, +5%); } } &--primary { color: $white !important; - background-color: $button-blue; + background-color: $blue; &:hover { - background-color: lighten($button-blue, 5%); + background-color: lighten($blue, 5%); } } diff --git a/src/newsreader/scss/elements/button/_mixins.scss b/src/newsreader/scss/elements/button/_mixins.scss deleted file mode 100644 index 75b70e3..0000000 --- a/src/newsreader/scss/elements/button/_mixins.scss +++ /dev/null @@ -1,3 +0,0 @@ -@mixin button-padding { - padding: 7px 40px; -} diff --git a/src/newsreader/scss/elements/button/_read-button.scss b/src/newsreader/scss/elements/button/_read-button.scss index 940d895..2c345b5 100644 --- a/src/newsreader/scss/elements/button/_read-button.scss +++ b/src/newsreader/scss/elements/button/_read-button.scss @@ -4,9 +4,9 @@ margin: 20px 0 0 0; color: $white; - background-color: $confirm-green; + background-color: $green; &:hover { - background-color: darken($confirm-green, 10%); + background-color: darken($green, 10%); } } diff --git a/src/newsreader/scss/elements/input/_input.scss b/src/newsreader/scss/elements/input/_input.scss index 8258020..16e6fad 100644 --- a/src/newsreader/scss/elements/input/_input.scss +++ b/src/newsreader/scss/elements/input/_input.scss @@ -1,7 +1,7 @@ .input { - padding: 10px; + @include text-padding; - border: 1px $border-gray solid; + border: 1px $gray solid; &:focus { border: 1px $focus-blue solid; diff --git a/src/newsreader/scss/elements/label/_label.scss b/src/newsreader/scss/elements/label/_label.scss index 5030a4c..6481b02 100644 --- a/src/newsreader/scss/elements/label/_label.scss +++ b/src/newsreader/scss/elements/label/_label.scss @@ -1,5 +1,5 @@ .label { - padding: 10px; + @include text-padding; } label { diff --git a/src/newsreader/scss/lib/_mixins.scss b/src/newsreader/scss/lib/_mixins.scss index 8b13789..72c9932 100644 --- a/src/newsreader/scss/lib/_mixins.scss +++ b/src/newsreader/scss/lib/_mixins.scss @@ -1 +1,11 @@ +@mixin text-padding { + padding: 10px; +} +@mixin block-padding { + padding: 15px; +} + +@mixin button-padding { + padding: 7px 40px; +} diff --git a/src/newsreader/scss/lib/index.scss b/src/newsreader/scss/lib/index.scss index ec6885e..026bf87 100644 --- a/src/newsreader/scss/lib/index.scss +++ b/src/newsreader/scss/lib/index.scss @@ -1 +1,2 @@ @import 'css.gg'; +@import 'mixins'; diff --git a/src/newsreader/scss/partials/_colors.scss b/src/newsreader/scss/partials/_colors.scss index aee33c2..b2f124d 100644 --- a/src/newsreader/scss/partials/_colors.scss +++ b/src/newsreader/scss/partials/_colors.scss @@ -1,44 +1,27 @@ -// light blue -$azureish-white: rgba(205, 230, 245, 1); - -// dark blue -$pewter-blue: rgba(141, 167, 190, 1); - -// light gray -$gainsboro: rgba(238, 238, 238, 1); - -// medium gray -$roman-silver: rgba(135, 145, 158, 1); - -//dark gray -$nickel: rgba(112, 112, 120, 1); - -$pink: rgba(235, 229, 229, 1); -$lavendal-pink: rgba(162, 155, 254, 1); - -$beige: rgba(245, 245, 220, 1); - -$light-green: rgba(230, 247, 185, 1); -$light-orange: rgba(255, 212, 153, 1); -$light-red: rgba(255, 118, 117, 1); - -$success-green: rgba(89, 181, 128, 1); -$error-red: lighten(rgba(231, 76, 60, 1), 10%); - -$confirm-green: $success-green; -$cancel-red: $error-red; - -$border-gray: rgba(227, 227, 227, 1); - -$button-blue: rgba(111, 164, 196, 1); -$focus-blue: darken($azureish-white, +10%); -$checkbox-blue: rgba(34, 170, 253, 1); -$font-color: rgba(48, 51, 53, 1); -$header-color: rgba(100, 101, 102, 1); +$orange: rgba(255, 212, 153, 1); +$green: rgba(89, 181, 128, 1); +$red: lighten(rgba(231, 76, 60, 1), 10%); +$gray: rgba(227, 227, 227, 1); +$blue: rgba(111, 164, 196, 1); $white: rgba(255, 255, 255, 1); $black: rgba(0, 0, 0, 1); -$blue: darken($azureish-white, +50%); $dark: rgba(0, 0, 0, 0.4); +$font-color: rgba(48, 51, 53, 1); +$header-color: rgba(100, 101, 102, 1); + $reddit-orange: rgba(255, 69, 0, 1); + +$transparant-red: transparentize($red, 0.8); +$transparant-blue: transparentize($blue, 0.8); +$transparant-orange: transparentize($orange, 0.4); +$transparant-green: transparentize($green, 0.4); + +$azureish-white: rgba(205, 230, 245, 1); +$gainsboro: rgba(238, 238, 238, 1); +$nickel: rgba(112, 112, 120, 1); +$lavendal-pink: rgba(162, 155, 254, 1); + +$focus-blue: darken($azureish-white, +10%); +$checkbox-blue: rgba(34, 170, 253, 1); diff --git a/src/newsreader/templates/components/form/form.html b/src/newsreader/templates/components/form/form.html index d854eb1..e183c25 100644 --- a/src/newsreader/templates/components/form/form.html +++ b/src/newsreader/templates/components/form/form.html @@ -26,7 +26,10 @@ {{ field.errors }} {{ field }} - {% include "components/form/help-text.html" %} + + {% if field.help_text %} + {% include "components/form/help-text.html" %} + {% endif %} {% endfor %} diff --git a/src/newsreader/templates/components/textbox/textbox.html b/src/newsreader/templates/components/textbox/textbox.html new file mode 100644 index 0000000..425cf60 --- /dev/null +++ b/src/newsreader/templates/components/textbox/textbox.html @@ -0,0 +1,13 @@ +
      + {% if title %} +

      {{ title }}

      + {% endif %} + + {% if body %} +

      {{ body }}

      + {% endif %} + + {% if footer %} + + {% endif %} +
      From 7af681887fb35dc2ec4b4f06a5672fddd8a06487 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Wed, 29 Jul 2020 22:43:42 +0200 Subject: [PATCH 148/422] Update CI jobs --- gitlab-ci/build.yml | 4 ++++ gitlab-ci/deploy.yml | 4 ++-- gitlab-ci/lint.yml | 12 +++++++++--- gitlab-ci/test.yml | 2 +- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/gitlab-ci/build.yml b/gitlab-ci/build.yml index c8df615..4d9854d 100644 --- a/gitlab-ci/build.yml +++ b/gitlab-ci/build.yml @@ -5,3 +5,7 @@ static: - npm install script: - npm run build + only: + refs: + - development + - merge_requests diff --git a/gitlab-ci/deploy.yml b/gitlab-ci/deploy.yml index 6eaa01f..05365df 100644 --- a/gitlab-ci/deploy.yml +++ b/gitlab-ci/deploy.yml @@ -1,11 +1,11 @@ deploy: stage: deploy - image: debian:buster + image: python:3.7 environment: name: production url: rss.fudiggity.nl before_script: - - apt-get update && apt-get install --quiet --quiet --assume-yes ansible git + - pip install ansible --quiet - git clone https://gitlab-ci-token:${CI_JOB_TOKEN}@git.fudiggity.nl/sonny/ansible-playbooks.git deployment - mkdir /root/.ssh - echo "192.168.178.63 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILbtcdgJBhVCKsO88cV19EYefDTopdYejEQCp1pYr1Ga" > /root/.ssh/known_hosts diff --git a/gitlab-ci/lint.yml b/gitlab-ci/lint.yml index 134716f..0300c33 100644 --- a/gitlab-ci/lint.yml +++ b/gitlab-ci/lint.yml @@ -1,7 +1,6 @@ python-linting: stage: lint - allow_failure: true - image: python:3.7.4-slim-stretch + image: python:3.7 before_script: - pip install poetry --quiet - poetry config cache-dir ~/.cache/poetry @@ -11,12 +10,19 @@ python-linting: - poetry run isort src/ --check-only --recursive - poetry run black src/ --line-length 88 --check - poetry run autoflake src/ --check --recursive --remove-all-unused-imports --ignore-init-module-imports + only: + refs: + - development + - merge_requests javascript-linting: stage: lint - allow_failure: true image: node:12 before_script: - npm install script: - npm run lint + only: + refs: + - development + - merge_requests diff --git a/gitlab-ci/test.yml b/gitlab-ci/test.yml index 723a0e8..3114a87 100644 --- a/gitlab-ci/test.yml +++ b/gitlab-ci/test.yml @@ -4,7 +4,7 @@ python-tests: services: - postgres:11 - memcached:1.5.22 - image: python:3.7.4-slim-stretch + image: python:3.7 before_script: - pip install poetry --quiet - poetry config cache-dir .cache/poetry From bea0257caeaebf81a7567cc3a74f6631705bc788 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Wed, 29 Jul 2020 22:47:32 +0200 Subject: [PATCH 149/422] Update CI jobs --- gitlab-ci/build.yml | 4 ++++ gitlab-ci/deploy.yml | 4 ++-- gitlab-ci/lint.yml | 12 +++++++++--- gitlab-ci/test.yml | 2 +- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/gitlab-ci/build.yml b/gitlab-ci/build.yml index c8df615..4d9854d 100644 --- a/gitlab-ci/build.yml +++ b/gitlab-ci/build.yml @@ -5,3 +5,7 @@ static: - npm install script: - npm run build + only: + refs: + - development + - merge_requests diff --git a/gitlab-ci/deploy.yml b/gitlab-ci/deploy.yml index 6eaa01f..05365df 100644 --- a/gitlab-ci/deploy.yml +++ b/gitlab-ci/deploy.yml @@ -1,11 +1,11 @@ deploy: stage: deploy - image: debian:buster + image: python:3.7 environment: name: production url: rss.fudiggity.nl before_script: - - apt-get update && apt-get install --quiet --quiet --assume-yes ansible git + - pip install ansible --quiet - git clone https://gitlab-ci-token:${CI_JOB_TOKEN}@git.fudiggity.nl/sonny/ansible-playbooks.git deployment - mkdir /root/.ssh - echo "192.168.178.63 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILbtcdgJBhVCKsO88cV19EYefDTopdYejEQCp1pYr1Ga" > /root/.ssh/known_hosts diff --git a/gitlab-ci/lint.yml b/gitlab-ci/lint.yml index 134716f..0300c33 100644 --- a/gitlab-ci/lint.yml +++ b/gitlab-ci/lint.yml @@ -1,7 +1,6 @@ python-linting: stage: lint - allow_failure: true - image: python:3.7.4-slim-stretch + image: python:3.7 before_script: - pip install poetry --quiet - poetry config cache-dir ~/.cache/poetry @@ -11,12 +10,19 @@ python-linting: - poetry run isort src/ --check-only --recursive - poetry run black src/ --line-length 88 --check - poetry run autoflake src/ --check --recursive --remove-all-unused-imports --ignore-init-module-imports + only: + refs: + - development + - merge_requests javascript-linting: stage: lint - allow_failure: true image: node:12 before_script: - npm install script: - npm run lint + only: + refs: + - development + - merge_requests diff --git a/gitlab-ci/test.yml b/gitlab-ci/test.yml index 723a0e8..3114a87 100644 --- a/gitlab-ci/test.yml +++ b/gitlab-ci/test.yml @@ -4,7 +4,7 @@ python-tests: services: - postgres:11 - memcached:1.5.22 - image: python:3.7.4-slim-stretch + image: python:3.7 before_script: - pip install poetry --quiet - poetry config cache-dir .cache/poetry From 7adb1cddb870cdc984c5b975f220e05e495b2205 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Thu, 30 Jul 2020 23:09:46 +0200 Subject: [PATCH 150/422] Add release job & update deploy job --- .gitlab-ci.yml | 2 ++ gitlab-ci/build.yml | 4 ---- gitlab-ci/deploy.yml | 8 ++++---- gitlab-ci/release.yml | 10 ++++++++++ 4 files changed, 16 insertions(+), 8 deletions(-) create mode 100644 gitlab-ci/release.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index fd895d6..beb864f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,6 +2,7 @@ stages: - build - test - lint + - release - deploy variables: @@ -25,4 +26,5 @@ include: - local: '/gitlab-ci/build.yml' - local: '/gitlab-ci/test.yml' - local: '/gitlab-ci/lint.yml' + - local: '/gitlab-ci/release.yml' - local: '/gitlab-ci/deploy.yml' diff --git a/gitlab-ci/build.yml b/gitlab-ci/build.yml index 4d9854d..c8df615 100644 --- a/gitlab-ci/build.yml +++ b/gitlab-ci/build.yml @@ -5,7 +5,3 @@ static: - npm install script: - npm run build - only: - refs: - - development - - merge_requests diff --git a/gitlab-ci/deploy.yml b/gitlab-ci/deploy.yml index 05365df..0fe3ce4 100644 --- a/gitlab-ci/deploy.yml +++ b/gitlab-ci/deploy.yml @@ -4,11 +4,13 @@ deploy: environment: name: production url: rss.fudiggity.nl + rules: + - if: $CI_COMMIT_TAG before_script: - pip install ansible --quiet - - git clone https://gitlab-ci-token:${CI_JOB_TOKEN}@git.fudiggity.nl/sonny/ansible-playbooks.git deployment + - git clone https://git.fudiggity.nl/sonny/newsreader.git deployment - mkdir /root/.ssh - - echo "192.168.178.63 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILbtcdgJBhVCKsO88cV19EYefDTopdYejEQCp1pYr1Ga" > /root/.ssh/known_hosts + - echo "$DEPLOY_HOST_KEY" > /root/.ssh/known_hosts - echo "$DEPLOY_KEY" > deployment/deploy_key && chmod 0600 deployment/deploy_key - mkdir /root/.vaults - echo "$VAULT_PASSWORD" > /root/.vaults/newsreader && chmod 0600 /root/.vaults/newsreader @@ -20,5 +22,3 @@ deploy: --user ansible --private-key deployment/deploy_key --vault-password-file /root/.vaults/newsreader - only: - - master diff --git a/gitlab-ci/release.yml b/gitlab-ci/release.yml new file mode 100644 index 0000000..cdc3f6f --- /dev/null +++ b/gitlab-ci/release.yml @@ -0,0 +1,10 @@ +release: + stage: release + image: registry.gitlab.com/gitlab-org/release-cli:latest + rules: + - if: $CI_COMMIT_TAG + script: + - echo 'running release_job' + release: + name: 'Release $CI_COMMIT_TAG' + ref: '$CI_COMMIT_TAG' From 4bca6a432f0fd3a6d12d122187661630d7bd9c56 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Thu, 30 Jul 2020 23:14:15 +0200 Subject: [PATCH 151/422] Fix invalid release job --- gitlab-ci/release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gitlab-ci/release.yml b/gitlab-ci/release.yml index cdc3f6f..d6abaf6 100644 --- a/gitlab-ci/release.yml +++ b/gitlab-ci/release.yml @@ -4,7 +4,9 @@ release: rules: - if: $CI_COMMIT_TAG script: - - echo 'running release_job' + - echo 'running release job' release: name: 'Release $CI_COMMIT_TAG' + description: 'Auto created release' + tag_name: '$CI_COMMIT_TAG' ref: '$CI_COMMIT_TAG' From 286971649a23b7e4de7819355adadb80e2bb8154 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Tue, 4 Aug 2020 20:20:28 +0200 Subject: [PATCH 152/422] Add version number to django settings Fixes #54 --- src/newsreader/conf/base.py | 4 ++++ src/newsreader/conf/production.py | 1 + src/newsreader/conf/version.py | 13 +++++++++++++ 3 files changed, 18 insertions(+) create mode 100644 src/newsreader/conf/version.py diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index b117b4f..43b89fd 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -2,6 +2,8 @@ import os from pathlib import Path +from .version import get_current_version + BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent DJANGO_PROJECT_DIR = os.path.join(BASE_DIR, "src", "newsreader") @@ -212,6 +214,8 @@ STATICFILES_FINDERS = [ DEFAULT_FROM_EMAIL = "newsreader@rss.fudiggity.nl" # Project settings +VERSION = get_current_version() + # Reddit integration REDDIT_CLIENT_ID = "CLIENT_ID" REDDIT_CLIENT_SECRET = "CLIENT_SECRET" diff --git a/src/newsreader/conf/production.py b/src/newsreader/conf/production.py index 5bc11a9..bfe9818 100644 --- a/src/newsreader/conf/production.py +++ b/src/newsreader/conf/production.py @@ -65,6 +65,7 @@ try: dsn=os.environ.get("SENTRY_DSN"), integrations=[DjangoIntegration(), CeleryIntegration()], send_default_pii=False, + release=VERSION, ) except ImportError: pass diff --git a/src/newsreader/conf/version.py b/src/newsreader/conf/version.py new file mode 100644 index 0000000..d91d770 --- /dev/null +++ b/src/newsreader/conf/version.py @@ -0,0 +1,13 @@ +import os +import subprocess + + +def get_current_version(): + if "VERSION" in os.environ: + return os.environ["VERSION"] + + try: + output = subprocess.check_output(["git", "describe"], universal_newlines=True) + return output.strip() + except (subprocess.CalledProcessError, OSError): + return "" From 78bc69629478d49dc106ea0a6a7311f5723c906e Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Tue, 4 Aug 2020 20:34:09 +0200 Subject: [PATCH 153/422] Fix white text in transparent error messages --- src/newsreader/scss/components/errorlist/_errorlist.scss | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/newsreader/scss/components/errorlist/_errorlist.scss b/src/newsreader/scss/components/errorlist/_errorlist.scss index d9c592c..382558d 100644 --- a/src/newsreader/scss/components/errorlist/_errorlist.scss +++ b/src/newsreader/scss/components/errorlist/_errorlist.scss @@ -4,8 +4,6 @@ margin: 5px 0; padding: 0; - color: $white; - list-style: disc; list-style-position: inside; From a820155fc0036717431b27391f353ecb21aae003 Mon Sep 17 00:00:00 2001 From: sonny Date: Tue, 4 Aug 2020 20:56:22 +0200 Subject: [PATCH 154/422] Fix category action test This was the same test as before -.- --- .gitlab-ci.yml | 2 ++ gitlab-ci/build.yml | 4 ---- gitlab-ci/deploy.yml | 8 ++++---- gitlab-ci/release.yml | 12 ++++++++++++ src/newsreader/conf/base.py | 4 ++++ src/newsreader/conf/production.py | 1 + src/newsreader/conf/version.py | 13 +++++++++++++ .../scss/components/errorlist/_errorlist.scss | 2 -- 8 files changed, 36 insertions(+), 10 deletions(-) create mode 100644 gitlab-ci/release.yml create mode 100644 src/newsreader/conf/version.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index fd895d6..beb864f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,6 +2,7 @@ stages: - build - test - lint + - release - deploy variables: @@ -25,4 +26,5 @@ include: - local: '/gitlab-ci/build.yml' - local: '/gitlab-ci/test.yml' - local: '/gitlab-ci/lint.yml' + - local: '/gitlab-ci/release.yml' - local: '/gitlab-ci/deploy.yml' diff --git a/gitlab-ci/build.yml b/gitlab-ci/build.yml index 4d9854d..c8df615 100644 --- a/gitlab-ci/build.yml +++ b/gitlab-ci/build.yml @@ -5,7 +5,3 @@ static: - npm install script: - npm run build - only: - refs: - - development - - merge_requests diff --git a/gitlab-ci/deploy.yml b/gitlab-ci/deploy.yml index 05365df..0fe3ce4 100644 --- a/gitlab-ci/deploy.yml +++ b/gitlab-ci/deploy.yml @@ -4,11 +4,13 @@ deploy: environment: name: production url: rss.fudiggity.nl + rules: + - if: $CI_COMMIT_TAG before_script: - pip install ansible --quiet - - git clone https://gitlab-ci-token:${CI_JOB_TOKEN}@git.fudiggity.nl/sonny/ansible-playbooks.git deployment + - git clone https://git.fudiggity.nl/sonny/newsreader.git deployment - mkdir /root/.ssh - - echo "192.168.178.63 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILbtcdgJBhVCKsO88cV19EYefDTopdYejEQCp1pYr1Ga" > /root/.ssh/known_hosts + - echo "$DEPLOY_HOST_KEY" > /root/.ssh/known_hosts - echo "$DEPLOY_KEY" > deployment/deploy_key && chmod 0600 deployment/deploy_key - mkdir /root/.vaults - echo "$VAULT_PASSWORD" > /root/.vaults/newsreader && chmod 0600 /root/.vaults/newsreader @@ -20,5 +22,3 @@ deploy: --user ansible --private-key deployment/deploy_key --vault-password-file /root/.vaults/newsreader - only: - - master diff --git a/gitlab-ci/release.yml b/gitlab-ci/release.yml new file mode 100644 index 0000000..d6abaf6 --- /dev/null +++ b/gitlab-ci/release.yml @@ -0,0 +1,12 @@ +release: + stage: release + image: registry.gitlab.com/gitlab-org/release-cli:latest + rules: + - if: $CI_COMMIT_TAG + script: + - echo 'running release job' + release: + name: 'Release $CI_COMMIT_TAG' + description: 'Auto created release' + tag_name: '$CI_COMMIT_TAG' + ref: '$CI_COMMIT_TAG' diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index b117b4f..43b89fd 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -2,6 +2,8 @@ import os from pathlib import Path +from .version import get_current_version + BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent DJANGO_PROJECT_DIR = os.path.join(BASE_DIR, "src", "newsreader") @@ -212,6 +214,8 @@ STATICFILES_FINDERS = [ DEFAULT_FROM_EMAIL = "newsreader@rss.fudiggity.nl" # Project settings +VERSION = get_current_version() + # Reddit integration REDDIT_CLIENT_ID = "CLIENT_ID" REDDIT_CLIENT_SECRET = "CLIENT_SECRET" diff --git a/src/newsreader/conf/production.py b/src/newsreader/conf/production.py index 5bc11a9..bfe9818 100644 --- a/src/newsreader/conf/production.py +++ b/src/newsreader/conf/production.py @@ -65,6 +65,7 @@ try: dsn=os.environ.get("SENTRY_DSN"), integrations=[DjangoIntegration(), CeleryIntegration()], send_default_pii=False, + release=VERSION, ) except ImportError: pass diff --git a/src/newsreader/conf/version.py b/src/newsreader/conf/version.py new file mode 100644 index 0000000..d91d770 --- /dev/null +++ b/src/newsreader/conf/version.py @@ -0,0 +1,13 @@ +import os +import subprocess + + +def get_current_version(): + if "VERSION" in os.environ: + return os.environ["VERSION"] + + try: + output = subprocess.check_output(["git", "describe"], universal_newlines=True) + return output.strip() + except (subprocess.CalledProcessError, OSError): + return "" diff --git a/src/newsreader/scss/components/errorlist/_errorlist.scss b/src/newsreader/scss/components/errorlist/_errorlist.scss index d9c592c..382558d 100644 --- a/src/newsreader/scss/components/errorlist/_errorlist.scss +++ b/src/newsreader/scss/components/errorlist/_errorlist.scss @@ -4,8 +4,6 @@ margin: 5px 0; padding: 0; - color: $white; - list-style: disc; list-style-position: inside; From 6fb848d90e70f2c28f93f5e338218d2c0713bd41 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Tue, 4 Aug 2020 21:06:54 +0200 Subject: [PATCH 155/422] Fix wrong url in deploy job --- gitlab-ci/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitlab-ci/deploy.yml b/gitlab-ci/deploy.yml index 0fe3ce4..07ba824 100644 --- a/gitlab-ci/deploy.yml +++ b/gitlab-ci/deploy.yml @@ -8,7 +8,7 @@ deploy: - if: $CI_COMMIT_TAG before_script: - pip install ansible --quiet - - git clone https://git.fudiggity.nl/sonny/newsreader.git deployment + - git clone https://git.fudiggity.nl/sonny/ansible-playbooks.git deployment - mkdir /root/.ssh - echo "$DEPLOY_HOST_KEY" > /root/.ssh/known_hosts - echo "$DEPLOY_KEY" > deployment/deploy_key && chmod 0600 deployment/deploy_key From aff108d7fc500bbfa901e51e81246b561025f5a4 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Tue, 4 Aug 2020 22:30:17 +0200 Subject: [PATCH 156/422] Allow using non-annotated tags for version --- src/newsreader/conf/version.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/newsreader/conf/version.py b/src/newsreader/conf/version.py index d91d770..f8b4c8d 100644 --- a/src/newsreader/conf/version.py +++ b/src/newsreader/conf/version.py @@ -7,7 +7,9 @@ def get_current_version(): return os.environ["VERSION"] try: - output = subprocess.check_output(["git", "describe"], universal_newlines=True) + output = subprocess.check_output( + ["git", "describe", "--tags"], universal_newlines=True + ) return output.strip() except (subprocess.CalledProcessError, OSError): return "" From bd9573cebcb950185662fe7cf3f53d2f9379fa14 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Wed, 5 Aug 2020 21:39:08 +0200 Subject: [PATCH 157/422] Show current version number in user agent --- src/newsreader/news/collection/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/newsreader/news/collection/utils.py b/src/newsreader/news/collection/utils.py index 8ba6fec..d47cd68 100644 --- a/src/newsreader/news/collection/utils.py +++ b/src/newsreader/news/collection/utils.py @@ -1,5 +1,6 @@ from datetime import datetime +from django.conf import settings from django.db.models.fields import CharField, TextField from django.template.defaultfilters import truncatechars from django.utils import timezone @@ -12,7 +13,7 @@ from requests.exceptions import RequestException from newsreader.news.collection.response_handler import ResponseHandler -DEFAULT_HEADERS = {"User-Agent": "linux:rss.fudiggity.nl:v0.2"} +DEFAULT_HEADERS = {"User-Agent": f"linux:rss.fudiggity.nl:{settings.VERSION}"} def build_publication_date(dt, tz): From ad51d17d2d6520db88eca8a465d86b766250d139 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Wed, 12 Aug 2020 09:31:50 +0200 Subject: [PATCH 158/422] Show feed URL's when catching feed client exceptions --- src/newsreader/news/collection/feed.py | 20 ++++++++++++++----- .../collection/tests/feed/client/tests.py | 18 ++++++----------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index 8018bb5..f67a109 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -161,17 +161,27 @@ class FeedClient(Client): stream.rule.last_suceeded = timezone.now() yield response_data + except (StreamNotFoundException, StreamTimeOutException) as e: + logger.warning(f"Request failed for {stream.rule.url}") + + self.set_rule_error(stream.rule, e) + + continue except StreamException as e: - logger.exception("Request failed") + logger.exception(f"Request failed for {stream.rule.url}") - length = stream.rule._meta.get_field("error").max_length - stream.rule.error = e.message[-length:] - stream.rule.succeeded = False + self.set_rule_error(stream.rule, e) - yield ({"entries": []}, stream) + continue finally: stream.rule.save() + def set_rule_error(self, rule, exception): + length = rule._meta.get_field("error").max_length + + rule.error = exception.message[-length:] + rule.succeeded = False + class FeedCollector(Collector): builder = FeedBuilder diff --git a/src/newsreader/news/collection/tests/feed/client/tests.py b/src/newsreader/news/collection/tests/feed/client/tests.py index 59b5f65..24eb214 100644 --- a/src/newsreader/news/collection/tests/feed/client/tests.py +++ b/src/newsreader/news/collection/tests/feed/client/tests.py @@ -41,13 +41,12 @@ class FeedClientTestCase(TestCase): def test_client_catches_stream_exception(self): rule = FeedFactory.create() - mock_stream = MagicMock(rule=rule) self.mocked_read.side_effect = StreamException(message="Stream exception") with FeedClient([rule]) as client: for data, stream in client: - self.assertEquals(data, {"entries": []}) + self.assertEquals(data, None) self.assertEquals(stream.rule.error, "Stream exception") self.assertEquals(stream.rule.succeeded, False) @@ -55,7 +54,6 @@ class FeedClientTestCase(TestCase): def test_client_catches_stream_not_found_exception(self): rule = FeedFactory.create() - mock_stream = MagicMock(rule=rule) self.mocked_read.side_effect = StreamNotFoundException( message="Stream not found" @@ -63,7 +61,7 @@ class FeedClientTestCase(TestCase): with FeedClient([rule]) as client: for data, stream in client: - self.assertEquals(data, {"entries": []}) + self.assertEquals(data, None) self.assertEquals(stream.rule.error, "Stream not found") self.assertEquals(stream.rule.succeeded, False) @@ -71,13 +69,12 @@ class FeedClientTestCase(TestCase): def test_client_catches_stream_denied_exception(self): rule = FeedFactory.create() - mock_stream = MagicMock(rule=rule) self.mocked_read.side_effect = StreamDeniedException(message="Stream denied") with FeedClient([rule]) as client: for data, stream in client: - self.assertEquals(data, {"entries": []}) + self.assertEquals(data, None) self.assertEquals(stream.rule.error, "Stream denied") self.assertEquals(stream.rule.succeeded, False) @@ -85,7 +82,6 @@ class FeedClientTestCase(TestCase): def test_client_catches_stream_timed_out(self): rule = FeedFactory.create() - mock_stream = MagicMock(rule=rule) self.mocked_read.side_effect = StreamTimeOutException( message="Stream timed out" @@ -93,7 +89,7 @@ class FeedClientTestCase(TestCase): with FeedClient([rule]) as client: for data, stream in client: - self.assertEquals(data, {"entries": []}) + self.assertEquals(data, None) self.assertEquals(stream.rule.error, "Stream timed out") self.assertEquals(stream.rule.succeeded, False) @@ -101,7 +97,6 @@ class FeedClientTestCase(TestCase): def test_client_catches_stream_parse_exception(self): rule = FeedFactory.create() - mock_stream = MagicMock(rule=rule) self.mocked_read.side_effect = StreamParseException( message="Stream has wrong contents" @@ -109,7 +104,7 @@ class FeedClientTestCase(TestCase): with FeedClient([rule]) as client: for data, stream in client: - self.assertEquals(data, {"entries": []}) + self.assertEquals(data, None) self.assertEquals(stream.rule.error, "Stream has wrong contents") self.assertEquals(stream.rule.succeeded, False) @@ -117,13 +112,12 @@ class FeedClientTestCase(TestCase): def test_client_catches_long_exception_text(self): rule = FeedFactory.create() - mock_stream = MagicMock(rule=rule) self.mocked_read.side_effect = StreamParseException(message=words(1000)) with FeedClient([rule]) as client: for data, stream in client: - self.assertEquals(data, {"entries": []}) + self.assertEquals(data, None) self.assertEquals(len(stream.rule.error), 1024) self.assertEquals(stream.rule.succeeded, False) From 128284dca31c010684fd9ac0634a914595e85de5 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Wed, 12 Aug 2020 09:35:45 +0200 Subject: [PATCH 159/422] Show URL's during feed exceptions & use version number in User-Agent --- src/newsreader/news/collection/feed.py | 20 ++++++++++++++----- .../collection/tests/feed/client/tests.py | 18 ++++++----------- src/newsreader/news/collection/utils.py | 3 ++- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index 8018bb5..f67a109 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -161,17 +161,27 @@ class FeedClient(Client): stream.rule.last_suceeded = timezone.now() yield response_data + except (StreamNotFoundException, StreamTimeOutException) as e: + logger.warning(f"Request failed for {stream.rule.url}") + + self.set_rule_error(stream.rule, e) + + continue except StreamException as e: - logger.exception("Request failed") + logger.exception(f"Request failed for {stream.rule.url}") - length = stream.rule._meta.get_field("error").max_length - stream.rule.error = e.message[-length:] - stream.rule.succeeded = False + self.set_rule_error(stream.rule, e) - yield ({"entries": []}, stream) + continue finally: stream.rule.save() + def set_rule_error(self, rule, exception): + length = rule._meta.get_field("error").max_length + + rule.error = exception.message[-length:] + rule.succeeded = False + class FeedCollector(Collector): builder = FeedBuilder diff --git a/src/newsreader/news/collection/tests/feed/client/tests.py b/src/newsreader/news/collection/tests/feed/client/tests.py index 59b5f65..24eb214 100644 --- a/src/newsreader/news/collection/tests/feed/client/tests.py +++ b/src/newsreader/news/collection/tests/feed/client/tests.py @@ -41,13 +41,12 @@ class FeedClientTestCase(TestCase): def test_client_catches_stream_exception(self): rule = FeedFactory.create() - mock_stream = MagicMock(rule=rule) self.mocked_read.side_effect = StreamException(message="Stream exception") with FeedClient([rule]) as client: for data, stream in client: - self.assertEquals(data, {"entries": []}) + self.assertEquals(data, None) self.assertEquals(stream.rule.error, "Stream exception") self.assertEquals(stream.rule.succeeded, False) @@ -55,7 +54,6 @@ class FeedClientTestCase(TestCase): def test_client_catches_stream_not_found_exception(self): rule = FeedFactory.create() - mock_stream = MagicMock(rule=rule) self.mocked_read.side_effect = StreamNotFoundException( message="Stream not found" @@ -63,7 +61,7 @@ class FeedClientTestCase(TestCase): with FeedClient([rule]) as client: for data, stream in client: - self.assertEquals(data, {"entries": []}) + self.assertEquals(data, None) self.assertEquals(stream.rule.error, "Stream not found") self.assertEquals(stream.rule.succeeded, False) @@ -71,13 +69,12 @@ class FeedClientTestCase(TestCase): def test_client_catches_stream_denied_exception(self): rule = FeedFactory.create() - mock_stream = MagicMock(rule=rule) self.mocked_read.side_effect = StreamDeniedException(message="Stream denied") with FeedClient([rule]) as client: for data, stream in client: - self.assertEquals(data, {"entries": []}) + self.assertEquals(data, None) self.assertEquals(stream.rule.error, "Stream denied") self.assertEquals(stream.rule.succeeded, False) @@ -85,7 +82,6 @@ class FeedClientTestCase(TestCase): def test_client_catches_stream_timed_out(self): rule = FeedFactory.create() - mock_stream = MagicMock(rule=rule) self.mocked_read.side_effect = StreamTimeOutException( message="Stream timed out" @@ -93,7 +89,7 @@ class FeedClientTestCase(TestCase): with FeedClient([rule]) as client: for data, stream in client: - self.assertEquals(data, {"entries": []}) + self.assertEquals(data, None) self.assertEquals(stream.rule.error, "Stream timed out") self.assertEquals(stream.rule.succeeded, False) @@ -101,7 +97,6 @@ class FeedClientTestCase(TestCase): def test_client_catches_stream_parse_exception(self): rule = FeedFactory.create() - mock_stream = MagicMock(rule=rule) self.mocked_read.side_effect = StreamParseException( message="Stream has wrong contents" @@ -109,7 +104,7 @@ class FeedClientTestCase(TestCase): with FeedClient([rule]) as client: for data, stream in client: - self.assertEquals(data, {"entries": []}) + self.assertEquals(data, None) self.assertEquals(stream.rule.error, "Stream has wrong contents") self.assertEquals(stream.rule.succeeded, False) @@ -117,13 +112,12 @@ class FeedClientTestCase(TestCase): def test_client_catches_long_exception_text(self): rule = FeedFactory.create() - mock_stream = MagicMock(rule=rule) self.mocked_read.side_effect = StreamParseException(message=words(1000)) with FeedClient([rule]) as client: for data, stream in client: - self.assertEquals(data, {"entries": []}) + self.assertEquals(data, None) self.assertEquals(len(stream.rule.error), 1024) self.assertEquals(stream.rule.succeeded, False) diff --git a/src/newsreader/news/collection/utils.py b/src/newsreader/news/collection/utils.py index 8ba6fec..d47cd68 100644 --- a/src/newsreader/news/collection/utils.py +++ b/src/newsreader/news/collection/utils.py @@ -1,5 +1,6 @@ from datetime import datetime +from django.conf import settings from django.db.models.fields import CharField, TextField from django.template.defaultfilters import truncatechars from django.utils import timezone @@ -12,7 +13,7 @@ from requests.exceptions import RequestException from newsreader.news.collection.response_handler import ResponseHandler -DEFAULT_HEADERS = {"User-Agent": "linux:rss.fudiggity.nl:v0.2"} +DEFAULT_HEADERS = {"User-Agent": f"linux:rss.fudiggity.nl:{settings.VERSION}"} def build_publication_date(dt, tz): From 03ac016dd34cd1e1f5eae82145e56effdcd1437c Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Wed, 12 Aug 2020 19:38:16 +0200 Subject: [PATCH 160/422] Fix FeedTask collecting reddit rules --- src/newsreader/news/collection/tasks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/newsreader/news/collection/tasks.py b/src/newsreader/news/collection/tasks.py index d368a5c..a04c5f9 100644 --- a/src/newsreader/news/collection/tasks.py +++ b/src/newsreader/news/collection/tasks.py @@ -8,6 +8,7 @@ from celery.utils.log import get_task_logger from newsreader.accounts.models import User from newsreader.celery import app +from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.feed import FeedCollector from newsreader.news.collection.utils import post from newsreader.utils.celery import MemCacheLock @@ -33,7 +34,7 @@ class FeedTask(app.Task): if acquired: logger.info(f"Running task for user {user_pk}") - rules = user.rules.enabled() + rules = user.rules.enabled().filter(type=RuleTypeChoices.feed) collector = FeedCollector() collector.collect(rules=rules) From d14aff1baad472d5025720c18afcceb761e173c7 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Wed, 12 Aug 2020 19:48:31 +0200 Subject: [PATCH 161/422] Update deploy job --- gitlab-ci/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitlab-ci/deploy.yml b/gitlab-ci/deploy.yml index 07ba824..85a2ba8 100644 --- a/gitlab-ci/deploy.yml +++ b/gitlab-ci/deploy.yml @@ -13,7 +13,7 @@ deploy: - echo "$DEPLOY_HOST_KEY" > /root/.ssh/known_hosts - echo "$DEPLOY_KEY" > deployment/deploy_key && chmod 0600 deployment/deploy_key - mkdir /root/.vaults - - echo "$VAULT_PASSWORD" > /root/.vaults/newsreader && chmod 0600 /root/.vaults/newsreader + - echo "$VAULT_PASSWORD" > /root/.vaults/newsreader script: - > ansible-playbook deployment/playbook.yml From 52a71a3f4ec3702e442e9e6b43e8ce5d8d08b360 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Wed, 12 Aug 2020 19:58:59 +0200 Subject: [PATCH 162/422] Fix FeedTask collecting reddit rules & update deploy job --- gitlab-ci/deploy.yml | 2 +- src/newsreader/news/collection/tasks.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/gitlab-ci/deploy.yml b/gitlab-ci/deploy.yml index 07ba824..85a2ba8 100644 --- a/gitlab-ci/deploy.yml +++ b/gitlab-ci/deploy.yml @@ -13,7 +13,7 @@ deploy: - echo "$DEPLOY_HOST_KEY" > /root/.ssh/known_hosts - echo "$DEPLOY_KEY" > deployment/deploy_key && chmod 0600 deployment/deploy_key - mkdir /root/.vaults - - echo "$VAULT_PASSWORD" > /root/.vaults/newsreader && chmod 0600 /root/.vaults/newsreader + - echo "$VAULT_PASSWORD" > /root/.vaults/newsreader script: - > ansible-playbook deployment/playbook.yml diff --git a/src/newsreader/news/collection/tasks.py b/src/newsreader/news/collection/tasks.py index d368a5c..a04c5f9 100644 --- a/src/newsreader/news/collection/tasks.py +++ b/src/newsreader/news/collection/tasks.py @@ -8,6 +8,7 @@ from celery.utils.log import get_task_logger from newsreader.accounts.models import User from newsreader.celery import app +from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.feed import FeedCollector from newsreader.news.collection.utils import post from newsreader.utils.celery import MemCacheLock @@ -33,7 +34,7 @@ class FeedTask(app.Task): if acquired: logger.info(f"Running task for user {user_pk}") - rules = user.rules.enabled() + rules = user.rules.enabled().filter(type=RuleTypeChoices.feed) collector = FeedCollector() collector.collect(rules=rules) From 34c5318c42640c64551824d1ac79b6641de10ccd Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Wed, 12 Aug 2020 20:22:00 +0200 Subject: [PATCH 163/422] Update deploy job --- gitlab-ci/deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gitlab-ci/deploy.yml b/gitlab-ci/deploy.yml index 85a2ba8..758ba55 100644 --- a/gitlab-ci/deploy.yml +++ b/gitlab-ci/deploy.yml @@ -13,7 +13,7 @@ deploy: - echo "$DEPLOY_HOST_KEY" > /root/.ssh/known_hosts - echo "$DEPLOY_KEY" > deployment/deploy_key && chmod 0600 deployment/deploy_key - mkdir /root/.vaults - - echo "$VAULT_PASSWORD" > /root/.vaults/newsreader + - echo "$VAULT_PASSWORD" > deployment/vault && chmod 0600 deployment/vault script: - > ansible-playbook deployment/playbook.yml @@ -21,4 +21,4 @@ deploy: --limit newsreader --user ansible --private-key deployment/deploy_key - --vault-password-file /root/.vaults/newsreader + --vault-password-file deployment/vault From c94158a3a667a1ac8485fa116fc2cd5eca6726c7 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Wed, 12 Aug 2020 20:31:43 +0200 Subject: [PATCH 164/422] Make vault file executable --- gitlab-ci/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitlab-ci/deploy.yml b/gitlab-ci/deploy.yml index 758ba55..8902721 100644 --- a/gitlab-ci/deploy.yml +++ b/gitlab-ci/deploy.yml @@ -13,7 +13,7 @@ deploy: - echo "$DEPLOY_HOST_KEY" > /root/.ssh/known_hosts - echo "$DEPLOY_KEY" > deployment/deploy_key && chmod 0600 deployment/deploy_key - mkdir /root/.vaults - - echo "$VAULT_PASSWORD" > deployment/vault && chmod 0600 deployment/vault + - echo "$VAULT_PASSWORD" > deployment/vault && chmod 0700 deployment/vault script: - > ansible-playbook deployment/playbook.yml From 1429e5a7ecc05172d14db79ccae3566218594100 Mon Sep 17 00:00:00 2001 From: sonny Date: Mon, 31 Aug 2020 22:38:59 +0200 Subject: [PATCH 165/422] Fix post sorting by rule --- .../js/pages/homepage/components/postlist/filters.js | 4 +++- .../collection/tests/endpoints/rule/list/tests.py | 11 ++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/newsreader/js/pages/homepage/components/postlist/filters.js b/src/newsreader/js/pages/homepage/components/postlist/filters.js index 59fd665..02d6c28 100644 --- a/src/newsreader/js/pages/homepage/components/postlist/filters.js +++ b/src/newsreader/js/pages/homepage/components/postlist/filters.js @@ -11,7 +11,9 @@ export const filterPostsByRule = (rule = {}, posts = []) => { const filteredData = filteredPosts.map(post => ({ ...post, rule: { ...rule } })); - return filteredData.length > 0 ? [...filteredData] : []; + return filteredData.sort((firstPost, secondPost) => { + return new Date(secondPost.publicationDate) - new Date(firstPost.publicationDate); + }); }; export const filterPostsByCategory = (category = {}, rules = [], posts = []) => { diff --git a/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py b/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py index 4d1ba8f..44e3eaa 100644 --- a/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py +++ b/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py @@ -165,7 +165,12 @@ class NestedRuleListViewTestCase(TestCase): def test_pagination(self): rule = FeedFactory.create(user=self.user) - FeedPostFactory.create_batch(size=80, rule=rule) + + posts = sorted( + FeedPostFactory.create_batch(size=80, rule=rule), + key=lambda post: post.publication_date, + reverse=True, + ) response = self.client.get( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}), @@ -177,6 +182,10 @@ class NestedRuleListViewTestCase(TestCase): self.assertEquals(data["count"], 80) self.assertEquals(len(data["results"]), 30) + self.assertEquals( + [post["id"] for post in data["results"]], [post.id for post in posts[:30]] + ) + def test_empty(self): rule = FeedFactory.create(user=self.user) From 30bd140483264eb8ed0937156a6b4d9e29bff4fe Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Mon, 31 Aug 2020 22:42:18 +0200 Subject: [PATCH 166/422] 0.2.6 - Fix sorting posts by rule --- gitlab-ci/deploy.yml | 8 ++++---- .../js/pages/homepage/components/postlist/filters.js | 4 +++- .../collection/tests/endpoints/rule/list/tests.py | 11 ++++++++++- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/gitlab-ci/deploy.yml b/gitlab-ci/deploy.yml index 8902721..24997ca 100644 --- a/gitlab-ci/deploy.yml +++ b/gitlab-ci/deploy.yml @@ -9,11 +9,11 @@ deploy: before_script: - pip install ansible --quiet - git clone https://git.fudiggity.nl/sonny/ansible-playbooks.git deployment - - mkdir /root/.ssh + - mkdir -p /root/.ssh - echo "$DEPLOY_HOST_KEY" > /root/.ssh/known_hosts - echo "$DEPLOY_KEY" > deployment/deploy_key && chmod 0600 deployment/deploy_key - - mkdir /root/.vaults - - echo "$VAULT_PASSWORD" > deployment/vault && chmod 0700 deployment/vault + - mkdir -p /root/.vaults + - echo "$VAULT_PASSWORD" > /root/.vaults/newsreader && chmod 0600 /root/.vaults/newsreader script: - > ansible-playbook deployment/playbook.yml @@ -21,4 +21,4 @@ deploy: --limit newsreader --user ansible --private-key deployment/deploy_key - --vault-password-file deployment/vault + --vault-password-file /root/.vaults/newsreader diff --git a/src/newsreader/js/pages/homepage/components/postlist/filters.js b/src/newsreader/js/pages/homepage/components/postlist/filters.js index 59fd665..02d6c28 100644 --- a/src/newsreader/js/pages/homepage/components/postlist/filters.js +++ b/src/newsreader/js/pages/homepage/components/postlist/filters.js @@ -11,7 +11,9 @@ export const filterPostsByRule = (rule = {}, posts = []) => { const filteredData = filteredPosts.map(post => ({ ...post, rule: { ...rule } })); - return filteredData.length > 0 ? [...filteredData] : []; + return filteredData.sort((firstPost, secondPost) => { + return new Date(secondPost.publicationDate) - new Date(firstPost.publicationDate); + }); }; export const filterPostsByCategory = (category = {}, rules = [], posts = []) => { diff --git a/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py b/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py index 4d1ba8f..44e3eaa 100644 --- a/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py +++ b/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py @@ -165,7 +165,12 @@ class NestedRuleListViewTestCase(TestCase): def test_pagination(self): rule = FeedFactory.create(user=self.user) - FeedPostFactory.create_batch(size=80, rule=rule) + + posts = sorted( + FeedPostFactory.create_batch(size=80, rule=rule), + key=lambda post: post.publication_date, + reverse=True, + ) response = self.client.get( reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}), @@ -177,6 +182,10 @@ class NestedRuleListViewTestCase(TestCase): self.assertEquals(data["count"], 80) self.assertEquals(len(data["results"]), 30) + self.assertEquals( + [post["id"] for post in data["results"]], [post.id for post in posts[:30]] + ) + def test_empty(self): rule = FeedFactory.create(user=self.user) From b035526848c08b4d82fba127610d8a55e4a1e672 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Mon, 31 Aug 2020 23:08:37 +0200 Subject: [PATCH 167/422] Fix npm vulnerabilities & update deploy job --- gitlab-ci/deploy.yml | 5 ++--- package-lock.json | 49 +++++++++++++++++++++++--------------------- package.json | 2 +- 3 files changed, 29 insertions(+), 27 deletions(-) diff --git a/gitlab-ci/deploy.yml b/gitlab-ci/deploy.yml index 24997ca..9afd4bd 100644 --- a/gitlab-ci/deploy.yml +++ b/gitlab-ci/deploy.yml @@ -12,8 +12,7 @@ deploy: - mkdir -p /root/.ssh - echo "$DEPLOY_HOST_KEY" > /root/.ssh/known_hosts - echo "$DEPLOY_KEY" > deployment/deploy_key && chmod 0600 deployment/deploy_key - - mkdir -p /root/.vaults - - echo "$VAULT_PASSWORD" > /root/.vaults/newsreader && chmod 0600 /root/.vaults/newsreader + - echo "$VAULT_PASSWORD" > deployment/vault && chmod 0600 deployment/vault script: - > ansible-playbook deployment/playbook.yml @@ -21,4 +20,4 @@ deploy: --limit newsreader --user ansible --private-key deployment/deploy_key - --vault-password-file /root/.vaults/newsreader + --vault-password-file deployment/vault diff --git a/package-lock.json b/package-lock.json index d884a42..416a18f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3087,9 +3087,9 @@ "dev": true }, "elliptic": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz", - "integrity": "sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw==", + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.3.tgz", + "integrity": "sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==", "dev": true, "requires": { "bn.js": "^4.4.0", @@ -4400,13 +4400,13 @@ "dev": true }, "globule": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/globule/-/globule-1.3.1.tgz", - "integrity": "sha512-OVyWOHgw29yosRHCHo7NncwR1hW5ew0W/UrvtwvjefVJeQ26q4/8r8FmPsSF1hJ93IgWkyv16pCTz6WblMzm/g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/globule/-/globule-1.3.2.tgz", + "integrity": "sha512-7IDTQTIu2xzXkT+6mlluidnWo+BypnbSoEVVQCGfzqnl5Ik8d3e1d4wycb8Rj9tWW+Z39uPWsdlquqiqPCd/pA==", "dev": true, "requires": { "glob": "~7.1.1", - "lodash": "~4.17.12", + "lodash": "~4.17.10", "minimatch": "~3.0.2" } }, @@ -5769,9 +5769,9 @@ } }, "js-base64": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.5.2.tgz", - "integrity": "sha512-Vg8czh0Q7sFBSUMWWArX/miJeBWYBPpdU/3M/DKSaekLMqrqVPaedp+5mZhie/r0lgrcaYBfwXatEew6gwgiQQ==", + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.6.4.tgz", + "integrity": "sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==", "dev": true }, "js-cookie": { @@ -5983,9 +5983,9 @@ } }, "lodash": { - "version": "4.17.15", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", - "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" }, "lodash.isequal": { "version": "4.5.0", @@ -7891,12 +7891,6 @@ "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", "dev": true }, - "serialize-javascript": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-2.1.2.tgz", - "integrity": "sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ==", - "dev": true - }, "set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -8468,16 +8462,16 @@ } }, "terser-webpack-plugin": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.3.tgz", - "integrity": "sha512-QMxecFz/gHQwteWwSo5nTc6UaICqN1bMedC5sMtUc7y3Ha3Q8y6ZO0iCR8pq4RJC8Hjf0FEPEHZqcMB/+DFCrA==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz", + "integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==", "dev": true, "requires": { "cacache": "^12.0.2", "find-cache-dir": "^2.1.0", "is-wsl": "^1.1.0", "schema-utils": "^1.0.0", - "serialize-javascript": "^2.1.2", + "serialize-javascript": "^4.0.0", "source-map": "^0.6.1", "terser": "^4.1.2", "webpack-sources": "^1.4.0", @@ -8495,6 +8489,15 @@ "ajv-keywords": "^3.1.0" } }, + "serialize-javascript": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", + "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index 1fec809..e6de6e4 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "dependencies": { "css.gg": "^1.0.6", "js-cookie": "^2.2.1", - "lodash": "^4.17.15", + "lodash": "^4.17.20", "object-assign": "^4.1.1", "react-redux": "^7.1.3", "redux": "^4.0.5", From 0d9163d363f871f3b843202a1be6716b71a22d67 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Tue, 1 Sep 2020 19:48:04 +0200 Subject: [PATCH 168/422] Fix truncating exotic values Fixes #65 --- .../collection/tests/feed/builder/mocks.py | 21 +++++++++++++++++++ .../collection/tests/feed/builder/tests.py | 16 ++++++++++++++ src/newsreader/news/collection/utils.py | 3 +-- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/newsreader/news/collection/tests/feed/builder/mocks.py b/src/newsreader/news/collection/tests/feed/builder/mocks.py index 83f7d0b..2ec57fd 100644 --- a/src/newsreader/news/collection/tests/feed/builder/mocks.py +++ b/src/newsreader/news/collection/tests/feed/builder/mocks.py @@ -260,6 +260,27 @@ mock_with_long_title = { ] } +mock_with_long_exotic_title = { + "entries": [ + { + "author": "A. Author", + "id": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "link": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "published": "Mon, 20 May 2019 16:07:37 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + "summary": "Foreign Minister Mohammad Javad Zarif says the US " + "president should try showing Iranians some respect.", + "title": "#ഡെബ്കോണ്‍ഫ്20 ഓണ്‍ലൈന്‍ അവസാന ദിവസം മലയാളം" + "പരിപാടികളോടെയാണു് തുടങ്ങുന്നതു്: നെറ്റ്‌വര്‍ക്ക് വഴി കുറേ കമ്പ്യൂട്ടറുകളില്‍" + "എളുപ്പത്തില്‍ ഡെബിയന്‍ ഇന്‍സ്റ്റോള്‍ ചെയ്യാം (ഉച്ചക്ക് ശേഷം 2:30 നു്)," + "സ്വതന്ത്ര സോഫ്റ്റ്‌വെയറിൽ കേരളത്തിലെ സ്ത്രീകളുടെ പങ്കാളിത്തം (ഉച്ചക്ക്" + "ശേഷം 3:30 നു്), ഗ്നു/ലിനക്സും ഗെയ്മിങ്ങും (വൈകുന്നേരം 4:30 നു്)," + "കേരളത്തിലൊരു ഡെബ്കോൺഫ് (വൈകുന്നേരം 5:30 നു്) https://" + "debconf20.debconf.org/schedule/?block=7", + } + ] +} + mock_with_longer_content_detail = { "entries": [ { diff --git a/src/newsreader/news/collection/tests/feed/builder/tests.py b/src/newsreader/news/collection/tests/feed/builder/tests.py index c3e60e0..4a6eb69 100644 --- a/src/newsreader/news/collection/tests/feed/builder/tests.py +++ b/src/newsreader/news/collection/tests/feed/builder/tests.py @@ -361,6 +361,22 @@ class FeedBuilderTestCase(TestCase): self.assertEquals(Post.objects.count(), 1) self.assertEquals(len(post.title), 200) + self.assertTrue(post.title.endswith("…")) + + def test_long_title_exotic_title(self): + builder = FeedBuilder + rule = FeedFactory() + mock_stream = MagicMock(rule=rule) + + with builder((mock_with_long_exotic_title, mock_stream)) as builder: + builder.save() + + post = Post.objects.get() + + self.assertEquals(Post.objects.count(), 1) + + self.assertEquals(len(post.title), 200) + self.assertTrue(post.title.endswith("…")) def test_content_detail_is_prioritized_if_longer(self): builder = FeedBuilder diff --git a/src/newsreader/news/collection/utils.py b/src/newsreader/news/collection/utils.py index d47cd68..4cfc0e7 100644 --- a/src/newsreader/news/collection/utils.py +++ b/src/newsreader/news/collection/utils.py @@ -2,7 +2,6 @@ from datetime import datetime from django.conf import settings from django.db.models.fields import CharField, TextField -from django.template.defaultfilters import truncatechars from django.utils import timezone import pytz @@ -66,6 +65,6 @@ def truncate_text(cls, field_name, value): return value if len(value) > max_length: - return truncatechars(value, max_length) + return f"{value[:max_length - 1]}…" return value From 47eaef40b36ae95b7686f465b67211b5d2f00f22 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Tue, 1 Sep 2020 22:02:47 +0200 Subject: [PATCH 169/422] Update deploy job to use file variables --- gitlab-ci/deploy.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/gitlab-ci/deploy.yml b/gitlab-ci/deploy.yml index 9afd4bd..ed429b7 100644 --- a/gitlab-ci/deploy.yml +++ b/gitlab-ci/deploy.yml @@ -9,15 +9,12 @@ deploy: before_script: - pip install ansible --quiet - git clone https://git.fudiggity.nl/sonny/ansible-playbooks.git deployment - - mkdir -p /root/.ssh - - echo "$DEPLOY_HOST_KEY" > /root/.ssh/known_hosts - - echo "$DEPLOY_KEY" > deployment/deploy_key && chmod 0600 deployment/deploy_key - - echo "$VAULT_PASSWORD" > deployment/vault && chmod 0600 deployment/vault + - mkdir /root/.ssh && echo "$DEPLOY_HOST_KEY" > /root/.ssh/known_hosts script: - > ansible-playbook deployment/playbook.yml --inventory deployment/apps.yml --limit newsreader --user ansible - --private-key deployment/deploy_key - --vault-password-file deployment/vault + --private-key "$DEPLOY_KEY" + --vault-password-file "$VAULT_FILE" From f0df342f6196ca6d16781a31faf325eb46583d28 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Tue, 1 Sep 2020 22:07:49 +0200 Subject: [PATCH 170/422] 0.2.6.2 - Update deploy job to use file variables - Fix truncating values with exotic characters --- gitlab-ci/deploy.yml | 9 +++----- .../collection/tests/feed/builder/mocks.py | 21 +++++++++++++++++++ .../collection/tests/feed/builder/tests.py | 16 ++++++++++++++ src/newsreader/news/collection/utils.py | 3 +-- 4 files changed, 41 insertions(+), 8 deletions(-) diff --git a/gitlab-ci/deploy.yml b/gitlab-ci/deploy.yml index 9afd4bd..ed429b7 100644 --- a/gitlab-ci/deploy.yml +++ b/gitlab-ci/deploy.yml @@ -9,15 +9,12 @@ deploy: before_script: - pip install ansible --quiet - git clone https://git.fudiggity.nl/sonny/ansible-playbooks.git deployment - - mkdir -p /root/.ssh - - echo "$DEPLOY_HOST_KEY" > /root/.ssh/known_hosts - - echo "$DEPLOY_KEY" > deployment/deploy_key && chmod 0600 deployment/deploy_key - - echo "$VAULT_PASSWORD" > deployment/vault && chmod 0600 deployment/vault + - mkdir /root/.ssh && echo "$DEPLOY_HOST_KEY" > /root/.ssh/known_hosts script: - > ansible-playbook deployment/playbook.yml --inventory deployment/apps.yml --limit newsreader --user ansible - --private-key deployment/deploy_key - --vault-password-file deployment/vault + --private-key "$DEPLOY_KEY" + --vault-password-file "$VAULT_FILE" diff --git a/src/newsreader/news/collection/tests/feed/builder/mocks.py b/src/newsreader/news/collection/tests/feed/builder/mocks.py index 83f7d0b..2ec57fd 100644 --- a/src/newsreader/news/collection/tests/feed/builder/mocks.py +++ b/src/newsreader/news/collection/tests/feed/builder/mocks.py @@ -260,6 +260,27 @@ mock_with_long_title = { ] } +mock_with_long_exotic_title = { + "entries": [ + { + "author": "A. Author", + "id": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "link": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "published": "Mon, 20 May 2019 16:07:37 GMT", + "published_parsed": struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), + "summary": "Foreign Minister Mohammad Javad Zarif says the US " + "president should try showing Iranians some respect.", + "title": "#ഡെബ്കോണ്‍ഫ്20 ഓണ്‍ലൈന്‍ അവസാന ദിവസം മലയാളം" + "പരിപാടികളോടെയാണു് തുടങ്ങുന്നതു്: നെറ്റ്‌വര്‍ക്ക് വഴി കുറേ കമ്പ്യൂട്ടറുകളില്‍" + "എളുപ്പത്തില്‍ ഡെബിയന്‍ ഇന്‍സ്റ്റോള്‍ ചെയ്യാം (ഉച്ചക്ക് ശേഷം 2:30 നു്)," + "സ്വതന്ത്ര സോഫ്റ്റ്‌വെയറിൽ കേരളത്തിലെ സ്ത്രീകളുടെ പങ്കാളിത്തം (ഉച്ചക്ക്" + "ശേഷം 3:30 നു്), ഗ്നു/ലിനക്സും ഗെയ്മിങ്ങും (വൈകുന്നേരം 4:30 നു്)," + "കേരളത്തിലൊരു ഡെബ്കോൺഫ് (വൈകുന്നേരം 5:30 നു്) https://" + "debconf20.debconf.org/schedule/?block=7", + } + ] +} + mock_with_longer_content_detail = { "entries": [ { diff --git a/src/newsreader/news/collection/tests/feed/builder/tests.py b/src/newsreader/news/collection/tests/feed/builder/tests.py index c3e60e0..4a6eb69 100644 --- a/src/newsreader/news/collection/tests/feed/builder/tests.py +++ b/src/newsreader/news/collection/tests/feed/builder/tests.py @@ -361,6 +361,22 @@ class FeedBuilderTestCase(TestCase): self.assertEquals(Post.objects.count(), 1) self.assertEquals(len(post.title), 200) + self.assertTrue(post.title.endswith("…")) + + def test_long_title_exotic_title(self): + builder = FeedBuilder + rule = FeedFactory() + mock_stream = MagicMock(rule=rule) + + with builder((mock_with_long_exotic_title, mock_stream)) as builder: + builder.save() + + post = Post.objects.get() + + self.assertEquals(Post.objects.count(), 1) + + self.assertEquals(len(post.title), 200) + self.assertTrue(post.title.endswith("…")) def test_content_detail_is_prioritized_if_longer(self): builder = FeedBuilder diff --git a/src/newsreader/news/collection/utils.py b/src/newsreader/news/collection/utils.py index d47cd68..4cfc0e7 100644 --- a/src/newsreader/news/collection/utils.py +++ b/src/newsreader/news/collection/utils.py @@ -2,7 +2,6 @@ from datetime import datetime from django.conf import settings from django.db.models.fields import CharField, TextField -from django.template.defaultfilters import truncatechars from django.utils import timezone import pytz @@ -66,6 +65,6 @@ def truncate_text(cls, field_name, value): return value if len(value) > max_length: - return truncatechars(value, max_length) + return f"{value[:max_length - 1]}…" return value From 65e4f3bb802b14af8e8c749910781c4ac381afa1 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Tue, 1 Sep 2020 22:27:24 +0200 Subject: [PATCH 171/422] 0.2.6.3 - Fallback to variable for vault password as file variables get execute permission set --- gitlab-ci/deploy.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gitlab-ci/deploy.yml b/gitlab-ci/deploy.yml index ed429b7..1520e4a 100644 --- a/gitlab-ci/deploy.yml +++ b/gitlab-ci/deploy.yml @@ -10,6 +10,7 @@ deploy: - pip install ansible --quiet - git clone https://git.fudiggity.nl/sonny/ansible-playbooks.git deployment - mkdir /root/.ssh && echo "$DEPLOY_HOST_KEY" > /root/.ssh/known_hosts + - echo $VAULT_PASSWORD > deployment/vault script: - > ansible-playbook deployment/playbook.yml @@ -17,4 +18,4 @@ deploy: --limit newsreader --user ansible --private-key "$DEPLOY_KEY" - --vault-password-file "$VAULT_FILE" + --vault-password-file deployment/vault From 805321f66dc9a150f8520bc899b620476da62bed Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Wed, 2 Sep 2020 21:37:33 +0200 Subject: [PATCH 172/422] 0.2.6.4 Update deploy job --- gitlab-ci/deploy.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/gitlab-ci/deploy.yml b/gitlab-ci/deploy.yml index 1520e4a..1d0df56 100644 --- a/gitlab-ci/deploy.yml +++ b/gitlab-ci/deploy.yml @@ -10,12 +10,13 @@ deploy: - pip install ansible --quiet - git clone https://git.fudiggity.nl/sonny/ansible-playbooks.git deployment - mkdir /root/.ssh && echo "$DEPLOY_HOST_KEY" > /root/.ssh/known_hosts - - echo $VAULT_PASSWORD > deployment/vault + - echo "$DEPLOY_KEY" > deployment/deploy_key && chmod 0600 deployment/deploy_key + - echo "$VAULT_PASSWORD" > deployment/vault script: - > ansible-playbook deployment/playbook.yml --inventory deployment/apps.yml --limit newsreader --user ansible - --private-key "$DEPLOY_KEY" + --private-key deployment/deploy_key --vault-password-file deployment/vault From 6120b26a44334ec634229743ad8ffdd389d1ef0f Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Wed, 9 Sep 2020 19:58:09 +0200 Subject: [PATCH 173/422] Update logging configuration --- src/newsreader/conf/base.py | 28 +++++----------------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 43b89fd..7b8c0b6 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -129,19 +129,14 @@ LOGGING = { "class": "logging.StreamHandler", "formatter": "timestamped", }, - "mail_admins": { - "level": "ERROR", - "filters": ["require_debug_false"], - "class": "django.utils.log.AdminEmailHandler", - }, - "syslog": { + "celery": { "level": "INFO", "filters": ["require_debug_false"], "class": "logging.handlers.SysLogHandler", "formatter": "syslog", "address": "/dev/log", }, - "syslog_errors": { + "syslog": { "level": "ERROR", "filters": ["require_debug_false"], "class": "logging.handlers.SysLogHandler", @@ -150,26 +145,13 @@ LOGGING = { }, }, "loggers": { - "django": { - "handlers": ["console", "mail_admins", "syslog_errors"], - "level": "WARNING", - }, + "django": {"handlers": ["console", "syslog"], "level": "INFO"}, "django.server": { - "handlers": ["console", "syslog_errors"], - "level": "INFO", - "propagate": False, - }, - "django.request": { - "handlers": ["console", "syslog_errors"], - "level": "INFO", - "propagate": False, - }, - "celery": {"handlers": ["syslog", "console"], "level": "INFO"}, - "celery.task": { - "handlers": ["syslog", "console"], + "handlers": ["console", "syslog"], "level": "INFO", "propagate": False, }, + "celery": {"handlers": ["celery", "console"], "level": "INFO"}, "newsreader": {"handlers": ["syslog", "console"], "level": "INFO"}, }, } From a7b4271a7d83f8148e54b229d1f28664a44aaf7a Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Wed, 9 Sep 2020 20:30:59 +0200 Subject: [PATCH 174/422] Update font configuration Fixes #63, See https://webpack.js.org/loaders/file-loader/#publicpath --- webpack.common.babel.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/webpack.common.babel.js b/webpack.common.babel.js index 4ad1700..bbfb403 100644 --- a/webpack.common.babel.js +++ b/webpack.common.babel.js @@ -26,8 +26,9 @@ export default { use: { loader: 'file-loader', options: { - name: 'fonts/[name].[ext]', - publicPath: '../', + name: '[name].[ext]', + outputPath: 'fonts', + publicPath: '/static/fonts/', }, }, }, From 40a027587b6d8a2f4cc08baeafcc0fc5db91238c Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Sun, 27 Sep 2020 16:08:30 +0200 Subject: [PATCH 175/422] Add Twitter integration Fixes #46 --- docker-compose.yml | 8 +- poetry.lock | 609 +- pyproject.toml | 2 + src/newsreader/accounts/admin.py | 18 +- .../migrations/0011_auto_20200913_2101.py | 21 + .../migrations/0012_remove_user_task.py | 10 + src/newsreader/accounts/models.py | 42 +- .../accounts/components/settings-form.html | 19 +- .../accounts/views/integrations.html | 70 + .../templates/accounts/views/reddit.html | 13 +- .../templates/accounts/views/twitter.html | 20 + .../accounts/tests/test_integrations.py | 537 ++ .../accounts/tests/test_settings.py | 130 - src/newsreader/accounts/tests/tests.py | 26 +- src/newsreader/accounts/urls.py | 41 +- src/newsreader/accounts/views.py | 210 - src/newsreader/accounts/views/__init__.py | 26 + src/newsreader/accounts/views/auth.py | 11 + src/newsreader/accounts/views/integrations.py | 343 + src/newsreader/accounts/views/password.py | 37 + src/newsreader/accounts/views/registration.py | 59 + src/newsreader/accounts/views/settings.py | 26 + src/newsreader/conf/base.py | 11 +- src/newsreader/conf/production.py | 11 +- src/newsreader/fixtures/default-fixture.json | 8046 ++++++++--------- src/newsreader/fixtures/local/fixture.json | 12 +- src/newsreader/js/pages/categories/App.js | 3 +- .../categories/components/CategoryCard.js | 2 +- src/newsreader/js/pages/categories/index.js | 12 +- src/newsreader/js/pages/homepage/App.js | 10 +- .../js/pages/homepage/components/PostModal.js | 23 +- .../homepage/components/postlist/PostItem.js | 20 +- .../homepage/components/postlist/PostList.js | 11 +- src/newsreader/js/pages/homepage/constants.js | 1 + src/newsreader/js/pages/homepage/index.js | 12 +- src/newsreader/news/collection/admin.py | 9 +- src/newsreader/news/collection/base.py | 118 +- src/newsreader/news/collection/choices.py | 7 + src/newsreader/news/collection/constants.py | 5 +- src/newsreader/news/collection/favicon.py | 93 +- src/newsreader/news/collection/feed.py | 124 +- .../news/collection/forms/__init__.py | 4 + src/newsreader/news/collection/forms/base.py | 29 + src/newsreader/news/collection/forms/feed.py | 28 + .../collection/{forms.py => forms/reddit.py} | 54 +- src/newsreader/news/collection/forms/rules.py | 14 + .../news/collection/forms/twitter.py | 35 + .../migrations/0009_auto_20200807_2030.py | 29 + .../migrations/0010_auto_20200913_2101.py | 24 + .../migrations/0011_auto_20200913_2157.py | 14 + src/newsreader/news/collection/models.py | 16 +- src/newsreader/news/collection/reddit.py | 260 +- src/newsreader/news/collection/tasks.py | 34 + .../{rule-create.html => feed-create.html} | 2 +- .../{rule-update.html => feed-update.html} | 6 +- .../news/collection/views/import.html | 2 +- .../news/collection/views/rules.html | 13 +- .../views/twitter/timeline-create.html | 9 + .../views/twitter/timeline-update.html | 14 + .../news/collection/tests/factories.py | 5 + .../collection/tests/favicon/builder/tests.py | 32 +- .../collection/tests/favicon/client/tests.py | 28 +- .../tests/favicon/collector/tests.py | 23 +- .../collection/tests/feed/builder/tests.py | 82 +- .../collection/tests/feed/client/tests.py | 4 +- .../collection/tests/feed/collector/tests.py | 82 +- .../collection/tests/feed/stream/tests.py | 6 +- .../collection/tests/reddit/builder/tests.py | 82 +- .../collection/tests/reddit/client/tests.py | 6 +- .../tests/reddit/collector/tests.py | 4 +- .../collection/tests/reddit/test_scheduler.py | 16 +- src/newsreader/news/collection/tests/tests.py | 96 +- .../news/collection/tests/twitter/__init__.py | 0 .../tests/twitter/builder/__init__.py | 0 .../collection/tests/twitter/builder/mocks.py | 2187 +++++ .../collection/tests/twitter/builder/tests.py | 412 + .../tests/twitter/client/__init__.py | 0 .../collection/tests/twitter/client/mocks.py | 225 + .../collection/tests/twitter/client/tests.py | 162 + .../tests/twitter/collector/__init__.py | 0 .../tests/twitter/collector/mocks.py | 227 + .../tests/twitter/collector/tests.py | 180 + .../tests/twitter/stream/__init__.py | 0 .../collection/tests/twitter/stream/mocks.py | 225 + .../collection/tests/twitter/stream/tests.py | 107 + .../tests/twitter/test_scheduler.py | 63 + .../news/collection/tests/utils/tests.py | 14 +- .../news/collection/tests/views/base.py | 2 +- .../news/collection/tests/views/test_crud.py | 18 +- .../tests/views/test_import_view.py | 4 +- .../tests/views/test_twitter_views.py | 129 + src/newsreader/news/collection/twitter.py | 281 + src/newsreader/news/collection/urls.py | 40 +- src/newsreader/news/collection/utils.py | 4 +- .../news/collection/views/__init__.py | 12 +- src/newsreader/news/collection/views/base.py | 28 +- src/newsreader/news/collection/views/feed.py | 70 + .../news/collection/views/reddit.py | 6 +- src/newsreader/news/collection/views/rules.py | 54 +- .../news/collection/views/twitter.py | 33 + .../templates/news/core/views/categories.html | 3 + .../templates/news/core/views/homepage.html | 11 +- src/newsreader/news/core/views.py | 34 +- .../scss/components/header/_header.scss | 3 + .../scss/components/header/index.scss | 1 + src/newsreader/scss/components/index.scss | 3 + .../integrations/_integrations.scss | 12 + .../scss/components/integrations/index.scss | 1 + .../scss/elements/button/_button.scss | 18 +- src/newsreader/scss/pages/index.scss | 1 + .../scss/pages/integrations/index.scss | 5 + src/newsreader/scss/partials/_colors.scss | 1 + .../templates/components/form/form.html | 2 +- .../templates/components/form/title.html | 3 - .../templates/components/header/header.html | 3 + src/newsreader/utils/opml.py | 1 + 116 files changed, 11005 insertions(+), 5441 deletions(-) create mode 100644 src/newsreader/accounts/migrations/0011_auto_20200913_2101.py create mode 100644 src/newsreader/accounts/migrations/0012_remove_user_task.py create mode 100644 src/newsreader/accounts/templates/accounts/views/integrations.html create mode 100644 src/newsreader/accounts/templates/accounts/views/twitter.html create mode 100644 src/newsreader/accounts/tests/test_integrations.py delete mode 100644 src/newsreader/accounts/views.py create mode 100644 src/newsreader/accounts/views/__init__.py create mode 100644 src/newsreader/accounts/views/auth.py create mode 100644 src/newsreader/accounts/views/integrations.py create mode 100644 src/newsreader/accounts/views/password.py create mode 100644 src/newsreader/accounts/views/registration.py create mode 100644 src/newsreader/accounts/views/settings.py create mode 100644 src/newsreader/news/collection/forms/__init__.py create mode 100644 src/newsreader/news/collection/forms/base.py create mode 100644 src/newsreader/news/collection/forms/feed.py rename src/newsreader/news/collection/{forms.py => forms/reddit.py} (51%) create mode 100644 src/newsreader/news/collection/forms/rules.py create mode 100644 src/newsreader/news/collection/forms/twitter.py create mode 100644 src/newsreader/news/collection/migrations/0009_auto_20200807_2030.py create mode 100644 src/newsreader/news/collection/migrations/0010_auto_20200913_2101.py create mode 100644 src/newsreader/news/collection/migrations/0011_auto_20200913_2157.py rename src/newsreader/news/collection/templates/news/collection/views/{rule-create.html => feed-create.html} (78%) rename src/newsreader/news/collection/templates/news/collection/views/{rule-update.html => feed-update.html} (72%) create mode 100644 src/newsreader/news/collection/templates/news/collection/views/twitter/timeline-create.html create mode 100644 src/newsreader/news/collection/templates/news/collection/views/twitter/timeline-update.html create mode 100644 src/newsreader/news/collection/tests/twitter/__init__.py create mode 100644 src/newsreader/news/collection/tests/twitter/builder/__init__.py create mode 100644 src/newsreader/news/collection/tests/twitter/builder/mocks.py create mode 100644 src/newsreader/news/collection/tests/twitter/builder/tests.py create mode 100644 src/newsreader/news/collection/tests/twitter/client/__init__.py create mode 100644 src/newsreader/news/collection/tests/twitter/client/mocks.py create mode 100644 src/newsreader/news/collection/tests/twitter/client/tests.py create mode 100644 src/newsreader/news/collection/tests/twitter/collector/__init__.py create mode 100644 src/newsreader/news/collection/tests/twitter/collector/mocks.py create mode 100644 src/newsreader/news/collection/tests/twitter/collector/tests.py create mode 100644 src/newsreader/news/collection/tests/twitter/stream/__init__.py create mode 100644 src/newsreader/news/collection/tests/twitter/stream/mocks.py create mode 100644 src/newsreader/news/collection/tests/twitter/stream/tests.py create mode 100644 src/newsreader/news/collection/tests/twitter/test_scheduler.py create mode 100644 src/newsreader/news/collection/tests/views/test_twitter_views.py create mode 100644 src/newsreader/news/collection/twitter.py create mode 100644 src/newsreader/news/collection/views/feed.py create mode 100644 src/newsreader/news/collection/views/twitter.py create mode 100644 src/newsreader/scss/components/header/_header.scss create mode 100644 src/newsreader/scss/components/header/index.scss create mode 100644 src/newsreader/scss/components/integrations/_integrations.scss create mode 100644 src/newsreader/scss/components/integrations/index.scss create mode 100644 src/newsreader/scss/pages/integrations/index.scss delete mode 100644 src/newsreader/templates/components/form/title.html create mode 100644 src/newsreader/templates/components/header/header.html diff --git a/docker-compose.yml b/docker-compose.yml index c7dc5ca..8ce24e3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3' +version: "3" volumes: postgres-data: static-files: @@ -16,7 +16,7 @@ services: rabbitmq: image: rabbitmq:3.7 memcached: - image: memcached:1.5.22 + image: memcached:1.6 ports: - "11211:11211" entrypoint: @@ -31,6 +31,7 @@ services: - DJANGO_SETTINGS_MODULE=newsreader.conf.docker depends_on: - rabbitmq + - memcached volumes: - .:/app django: @@ -41,9 +42,10 @@ services: environment: - DJANGO_SETTINGS_MODULE=newsreader.conf.docker ports: - - '8000:8000' + - "8000:8000" depends_on: - db + - memcached volumes: - .:/app - static-files:/app/src/newsreader/static diff --git a/poetry.lock b/poetry.lock index cab45d1..0bbd4e5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,40 +1,40 @@ [[package]] -category = "main" -description = "Low-level AMQP client for Python (fork of amqplib)." name = "amqp" +version = "2.5.2" +description = "Low-level AMQP client for Python (fork of amqplib)." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.5.2" [package.dependencies] vine = ">=1.1.3,<5.0.0a1" [[package]] -category = "dev" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." name = "appdirs" +version = "1.4.3" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" optional = false python-versions = "*" -version = "1.4.3" [[package]] -category = "main" -description = "ASGI specs, helper code, and adapters" name = "asgiref" +version = "3.2.7" +description = "ASGI specs, helper code, and adapters" +category = "main" optional = false python-versions = ">=3.5" -version = "3.2.7" [package.extras] tests = ["pytest (>=4.3.0,<4.4.0)", "pytest-asyncio (>=0.10.0,<0.11.0)"] [[package]] -category = "dev" -description = "Classes Without Boilerplate" name = "attrs" +version = "19.3.0" +description = "Classes Without Boilerplate" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "19.3.0" [package.extras] azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"] @@ -43,46 +43,49 @@ docs = ["sphinx", "zope.interface"] tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] [[package]] -category = "dev" -description = "Removes unused imports and unused variables" name = "autoflake" +version = "1.3.1" +description = "Removes unused imports and unused variables" +category = "dev" optional = false python-versions = "*" -version = "1.3.1" [package.dependencies] pyflakes = ">=1.1.0" [[package]] -category = "main" -description = "Screen-scraping library" name = "beautifulsoup4" +version = "4.9.0" +description = "Screen-scraping library" +category = "main" optional = false python-versions = "*" -version = "4.9.0" - -[package.dependencies] -soupsieve = [">1.2", "<2.0"] [package.extras] html5lib = ["html5lib"] lxml = ["lxml"] -[[package]] -category = "main" -description = "Python multiprocessing fork with improvements and bugfixes" -name = "billiard" -optional = false -python-versions = "*" -version = "3.6.3.0" +[package.dependencies] +soupsieve = [">1.2", "<2.0"] + +[[package]] +name = "billiard" +version = "3.6.3.0" +description = "Python multiprocessing fork with improvements and bugfixes" +category = "main" +optional = false +python-versions = "*" [[package]] -category = "dev" -description = "The uncompromising code formatter." name = "black" +version = "19.3b0" +description = "The uncompromising code formatter." +category = "dev" optional = false python-versions = ">=3.6" -version = "19.3b0" + +[package.extras] +d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] [package.dependencies] appdirs = "*" @@ -90,34 +93,25 @@ attrs = ">=18.1.0" click = ">=6.5" toml = ">=0.9.4" -[package.extras] -d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] - [[package]] -category = "main" -description = "An easy safelist-based HTML-sanitizing tool." name = "bleach" +version = "3.1.4" +description = "An easy safelist-based HTML-sanitizing tool." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "3.1.4" [package.dependencies] six = ">=1.9.0" webencodings = "*" [[package]] -category = "main" -description = "Distributed Task Queue." name = "celery" +version = "4.4.2" +description = "Distributed Task Queue." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*," -version = "4.4.2" - -[package.dependencies] -billiard = ">=3.6.3.0,<4.0" -kombu = ">=4.6.8,<4.7" -pytz = ">0.0-dev" -vine = "1.3.0" [package.extras] arangodb = ["pyArango (>=1.3.2)"] @@ -153,37 +147,43 @@ yaml = ["PyYAML (>=3.10)"] zookeeper = ["kazoo (>=1.3.1)"] zstd = ["zstandard"] +[package.dependencies] +billiard = ">=3.6.3.0,<4.0" +kombu = ">=4.6.8,<4.7" +pytz = ">0.0-dev" +vine = "1.3.0" + [[package]] -category = "main" -description = "Python package for providing Mozilla's CA Bundle." name = "certifi" -optional = false -python-versions = "*" version = "2020.4.5.1" - -[[package]] +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "chardet" +version = "3.0.4" +description = "Universal encoding detector for Python 2 and 3" category = "main" -description = "Universal encoding detector for Python 2 and 3" -name = "chardet" optional = false python-versions = "*" -version = "3.0.4" [[package]] -category = "dev" -description = "Composable command line interface toolkit" name = "click" +version = "7.1.1" +description = "Composable command line interface toolkit" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "7.1.1" [[package]] -category = "main" -description = "Python client library for Core API." name = "coreapi" +version = "2.3.3" +description = "Python client library for Core API." +category = "main" optional = false python-versions = "*" -version = "2.3.3" [package.dependencies] coreschema = "*" @@ -192,62 +192,62 @@ requests = "*" uritemplate = "*" [[package]] -category = "main" -description = "Core Schema." name = "coreschema" +version = "0.0.4" +description = "Core Schema." +category = "main" optional = false python-versions = "*" -version = "0.0.4" [package.dependencies] jinja2 = "*" [[package]] -category = "dev" -description = "Code coverage measurement for Python" name = "coverage" +version = "5.1" +description = "Code coverage measurement for Python" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" -version = "5.1" [package.extras] toml = ["toml"] [[package]] -category = "main" -description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." name = "django" +version = "3.0.7" +description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." +category = "main" optional = false python-versions = ">=3.6" -version = "3.0.7" + +[package.extras] +argon2 = ["argon2-cffi (>=16.1.0)"] +bcrypt = ["bcrypt"] [package.dependencies] asgiref = ">=3.2,<4.0" pytz = "*" sqlparse = ">=0.2.2" -[package.extras] -argon2 = ["argon2-cffi (>=16.1.0)"] -bcrypt = ["bcrypt"] - [[package]] -category = "main" -description = "A helper class for handling configuration defaults of packaged apps gracefully." name = "django-appconf" +version = "1.0.4" +description = "A helper class for handling configuration defaults of packaged apps gracefully." +category = "main" optional = false python-versions = "*" -version = "1.0.4" [package.dependencies] django = "*" [[package]] -category = "main" -description = "Keep track of failed login attempts in Django-powered sites." name = "django-axes" +version = "5.3.1" +description = "Keep track of failed login attempts in Django-powered sites." +category = "main" optional = false python-versions = "~=3.6" -version = "5.3.1" [package.dependencies] django = ">=1.11" @@ -255,93 +255,96 @@ django-appconf = ">=1.0.3" django-ipware = ">=2.0.2" [[package]] -category = "main" -description = "Database-backed Periodic Tasks." name = "django-celery-beat" +version = "2.0.0" +description = "Database-backed Periodic Tasks." +category = "main" optional = false python-versions = "*" -version = "2.0.0" [package.dependencies] -Django = ">=1.11.17" celery = "*" +Django = ">=1.11.17" django-timezone-field = ">=4.0,<5.0" python-crontab = ">=2.3.4" [[package]] -category = "dev" -description = "A configurable set of panels that display various debug information about the current request/response." name = "django-debug-toolbar" +version = "2.2" +description = "A configurable set of panels that display various debug information about the current request/response." +category = "dev" optional = false python-versions = ">=3.5" -version = "2.2" [package.dependencies] Django = ">=1.11" sqlparse = ">=0.2.0" [[package]] -category = "dev" -description = "Extensions for Django" name = "django-extensions" +version = "2.2.9" +description = "Extensions for Django" +category = "dev" optional = false python-versions = "*" -version = "2.2.9" [package.dependencies] six = ">=1.2" [[package]] -category = "main" -description = "A Django utility application that returns client's real IP address" name = "django-ipware" -optional = false -python-versions = "*" version = "2.1.0" - -[[package]] +description = "A Django utility application that returns client's real IP address" category = "main" -description = "An extensible user-registration application for Django" -name = "django-registration-redux" optional = false python-versions = "*" -version = "2.7" [[package]] +name = "django-registration-redux" +version = "2.7" +description = "An extensible user-registration application for Django" category = "main" -description = "A Django app providing database and form fields for pytz timezone objects." +optional = false +python-versions = "*" + +[[package]] name = "django-timezone-field" +version = "4.0" +description = "A Django app providing database and form fields for pytz timezone objects." +category = "main" optional = false python-versions = ">=3.5" -version = "4.0" [package.dependencies] django = ">=2.2" pytz = "*" [[package]] -category = "main" -description = "Web APIs for Django, made easy." name = "djangorestframework" +version = "3.11.0" +description = "Web APIs for Django, made easy." +category = "main" optional = false python-versions = ">=3.5" -version = "3.11.0" [package.dependencies] django = ">=1.11" [[package]] -category = "main" -description = "Automated generation of real Swagger/OpenAPI 2.0 schemas from Django Rest Framework code." name = "drf-yasg" +version = "1.17.1" +description = "Automated generation of real Swagger/OpenAPI 2.0 schemas from Django Rest Framework code." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "1.17.1" + +[package.extras] +validation = ["swagger-spec-validator (>=2.1.0)"] [package.dependencies] -Django = ">=1.11.7" coreapi = ">=2.3.3" coreschema = ">=0.0.4" +Django = ">=1.11.7" djangorestframework = ">=3.8" inflection = ">=0.3.1" packaging = "*" @@ -349,62 +352,67 @@ packaging = "*" six = ">=1.10.0" uritemplate = ">=3.0.0" -[package.extras] -validation = ["swagger-spec-validator (>=2.1.0)"] - [[package]] -category = "dev" -description = "A versatile test fixtures replacement based on thoughtbot's factory_bot for Ruby." name = "factory-boy" +version = "2.12.0" +description = "A versatile test fixtures replacement based on thoughtbot's factory_bot for Ruby." +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.12.0" [package.dependencies] Faker = ">=0.7.0" [[package]] -category = "dev" -description = "Faker is a Python package that generates fake data for you." name = "faker" +version = "4.0.2" +description = "Faker is a Python package that generates fake data for you." +category = "dev" optional = false python-versions = ">=3.4" -version = "4.0.2" [package.dependencies] python-dateutil = ">=2.4" text-unidecode = "1.3" [[package]] -category = "main" -description = "Universal feed parser, handles RSS 0.9x, RSS 1.0, RSS 2.0, CDF, Atom 0.3, and Atom 1.0 feeds" name = "feedparser" +version = "5.2.1" +description = "Universal feed parser, handles RSS 0.9x, RSS 1.0, RSS 2.0, CDF, Atom 0.3, and Atom 1.0 feeds" +category = "main" optional = false python-versions = "*" -version = "5.2.1" [[package]] -category = "dev" -description = "Let your Python tests travel through time" name = "freezegun" +version = "0.3.15" +description = "Let your Python tests travel through time" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "0.3.15" [package.dependencies] python-dateutil = ">=1.0,<2.0 || >2.0" six = "*" [[package]] +name = "ftfy" +version = "5.8" +description = "Fixes some problems with Unicode text after the fact" category = "main" -description = "WSGI HTTP Server for UNIX" -name = "gunicorn" optional = false -python-versions = ">=3.4" -version = "20.0.4" +python-versions = ">=3.5" [package.dependencies] -setuptools = ">=3.0" +wcwidth = "*" + +[[package]] +name = "gunicorn" +version = "20.0.4" +description = "WSGI HTTP Server for UNIX" +category = "main" +optional = false +python-versions = ">=3.4" [package.extras] eventlet = ["eventlet (>=0.9.7)"] @@ -412,45 +420,48 @@ gevent = ["gevent (>=0.13)"] setproctitle = ["setproctitle"] tornado = ["tornado (>=0.2)"] +[package.dependencies] +setuptools = ">=3.0" + [[package]] -category = "main" -description = "Internationalized Domain Names in Applications (IDNA)" name = "idna" +version = "2.9" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.9" [[package]] -category = "main" -description = "Read metadata from Python packages" -marker = "python_version < \"3.8\"" name = "importlib-metadata" +version = "1.6.0" +description = "Read metadata from Python packages" +category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" -version = "1.6.0" - -[package.dependencies] -zipp = ">=0.5" +marker = "python_version < \"3.8\"" [package.extras] docs = ["sphinx", "rst.linker"] testing = ["packaging", "importlib-resources"] -[[package]] -category = "main" -description = "A port of Ruby on Rails inflector to Python" -name = "inflection" -optional = false -python-versions = ">=3.5" -version = "0.4.0" +[package.dependencies] +zipp = ">=0.5" + +[[package]] +name = "inflection" +version = "0.4.0" +description = "A port of Ruby on Rails inflector to Python" +category = "main" +optional = false +python-versions = ">=3.5" [[package]] -category = "dev" -description = "A Python utility / library to sort Python imports." name = "isort" +version = "4.3.21" +description = "A Python utility / library to sort Python imports." +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "4.3.21" [package.extras] pipfile = ["pipreqs", "requirementslib"] @@ -459,41 +470,34 @@ requirements = ["pipreqs", "pip-api"] xdg_home = ["appdirs (>=1.4.0)"] [[package]] -category = "main" -description = "Simple immutable types for python." name = "itypes" +version = "1.1.0" +description = "Simple immutable types for python." +category = "main" optional = false python-versions = "*" -version = "1.1.0" [[package]] -category = "main" -description = "A very fast and expressive template engine." name = "jinja2" +version = "2.11.1" +description = "A very fast and expressive template engine." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.11.1" - -[package.dependencies] -MarkupSafe = ">=0.23" [package.extras] i18n = ["Babel (>=0.8)"] +[package.dependencies] +MarkupSafe = ">=0.23" + [[package]] -category = "main" -description = "Messaging library for Python." name = "kombu" +version = "4.6.8" +description = "Messaging library for Python." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "4.6.8" - -[package.dependencies] -amqp = ">=2.5.2,<2.6" - -[package.dependencies.importlib-metadata] -python = "<3.8" -version = ">=0.18" [package.extras] azureservicebus = ["azure-servicebus (>=0.21.1)"] @@ -511,13 +515,20 @@ sqs = ["boto3 (>=1.4.4)", "pycurl (7.43.0.2)"] yaml = ["PyYAML (>=3.10)"] zookeeper = ["kazoo (>=1.3.1)"] +[package.dependencies] +amqp = ">=2.5.2,<2.6" + +[package.dependencies.importlib-metadata] +version = ">=0.18" +python = "<3.8" + [[package]] -category = "main" -description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." name = "lxml" +version = "4.5.0" +description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*" -version = "4.5.0" [package.extras] cssselect = ["cssselect (>=0.7)"] @@ -526,112 +537,129 @@ htmlsoup = ["beautifulsoup4"] source = ["Cython (>=0.29.7)"] [[package]] -category = "main" -description = "Safely add untrusted strings to HTML/XML markup." name = "markupsafe" +version = "1.1.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "main" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" -version = "1.1.1" [[package]] +name = "oauthlib" +version = "3.1.0" +description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" category = "main" -description = "Core utilities for Python packages" -name = "packaging" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +rsa = ["cryptography"] +signals = ["blinker"] +signedtoken = ["cryptography", "pyjwt (>=1.0.0)"] + +[[package]] +name = "packaging" version = "20.3" +description = "Core utilities for Python packages" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.dependencies] pyparsing = ">=2.0.2" six = "*" [[package]] -category = "main" -description = "psycopg2 - Python-PostgreSQL Database Adapter" name = "psycopg2-binary" +version = "2.8.5" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +category = "main" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" -version = "2.8.5" [[package]] -category = "dev" -description = "passive checker of Python programs" name = "pyflakes" +version = "2.2.0" +description = "passive checker of Python programs" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.2.0" [[package]] -category = "main" -description = "Python parsing module" name = "pyparsing" +version = "2.4.7" +description = "Python parsing module" +category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -version = "2.4.7" [[package]] -category = "main" -description = "Python Crontab API" name = "python-crontab" +version = "2.4.1" +description = "Python Crontab API" +category = "main" optional = false python-versions = "*" -version = "2.4.1" - -[package.dependencies] -python-dateutil = "*" [package.extras] cron-description = ["cron-descriptor"] cron-schedule = ["croniter"] +[package.dependencies] +python-dateutil = "*" + [[package]] -category = "main" -description = "Extensions to the standard Python datetime module" name = "python-dateutil" +version = "2.8.1" +description = "Extensions to the standard Python datetime module" +category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -version = "2.8.1" [package.dependencies] six = ">=1.5" [[package]] -category = "main" -description = "Add .env support to your django/flask apps in development and deployments" name = "python-dotenv" +version = "0.12.0" +description = "Add .env support to your django/flask apps in development and deployments" +category = "main" optional = false python-versions = "*" -version = "0.12.0" [package.extras] cli = ["click (>=5.0)"] [[package]] -category = "main" -description = "Pure python memcached client" name = "python-memcached" +version = "1.59" +description = "Pure python memcached client" +category = "main" optional = false python-versions = "*" -version = "1.59" [package.dependencies] six = ">=1.4.0" [[package]] -category = "main" -description = "World timezone definitions, modern and historical" name = "pytz" +version = "2019.3" +description = "World timezone definitions, modern and historical" +category = "main" optional = false python-versions = "*" -version = "2019.3" [[package]] -category = "main" -description = "Python HTTP for Humans." name = "requests" +version = "2.23.0" +description = "Python HTTP for Humans." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.23.0" + +[package.extras] +security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] +socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] [package.dependencies] certifi = ">=2017.4.17" @@ -639,47 +667,54 @@ chardet = ">=3.0.2,<4" idna = ">=2.5,<3" urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" -[package.extras] -security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] -socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] - [[package]] +name = "requests-oauthlib" +version = "1.3.0" +description = "OAuthlib authentication support for Requests." category = "main" -description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" -name = "ruamel.yaml" optional = false -python-versions = "*" -version = "0.16.10" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +rsa = ["oauthlib (>=3.0.0)"] [package.dependencies] -[package.dependencies."ruamel.yaml.clib"] -python = "<3.9" -version = ">=0.1.2" +oauthlib = ">=3.0.0" +requests = ">=2.0.0" + +[[package]] +name = "ruamel.yaml" +version = "0.16.10" +description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" +category = "main" +optional = false +python-versions = "*" [package.extras] docs = ["ryd"] jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] -[[package]] -category = "main" -description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" -marker = "platform_python_implementation == \"CPython\" and python_version < \"3.9\"" -name = "ruamel.yaml.clib" -optional = false -python-versions = "*" -version = "0.2.0" - -[[package]] -category = "main" -description = "Python client for Sentry (https://getsentry.com)" -name = "sentry-sdk" -optional = false -python-versions = "*" -version = "0.15.1" - [package.dependencies] -certifi = "*" -urllib3 = ">=1.10.0" +[package.dependencies."ruamel.yaml.clib"] +version = ">=0.1.2" +python = "<3.9" + +[[package]] +name = "ruamel.yaml.clib" +version = "0.2.0" +description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" +category = "main" +optional = false +python-versions = "*" +marker = "platform_python_implementation == \"CPython\" and python_version < \"3.9\"" + +[[package]] +name = "sentry-sdk" +version = "0.15.1" +description = "Python client for Sentry (https://getsentry.com)" +category = "main" +optional = false +python-versions = "*" [package.extras] aiohttp = ["aiohttp (>=3.5)"] @@ -695,69 +730,73 @@ sanic = ["sanic (>=0.8)"] sqlalchemy = ["sqlalchemy (>=1.2)"] tornado = ["tornado (>=5)"] +[package.dependencies] +certifi = "*" +urllib3 = ">=1.10.0" + [[package]] -category = "main" -description = "Python 2 and 3 compatibility utilities" name = "six" +version = "1.14.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -version = "1.14.0" [[package]] -category = "main" -description = "A modern CSS selector implementation for Beautiful Soup." name = "soupsieve" +version = "1.9.5" +description = "A modern CSS selector implementation for Beautiful Soup." +category = "main" optional = false python-versions = "*" -version = "1.9.5" [[package]] -category = "main" -description = "Non-validating SQL parser" name = "sqlparse" +version = "0.3.1" +description = "Non-validating SQL parser" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "0.3.1" [[package]] -category = "dev" -description = "Traceback serialization library." name = "tblib" +version = "1.6.0" +description = "Traceback serialization library." +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "1.6.0" [[package]] -category = "dev" -description = "The most basic Text::Unidecode port" name = "text-unidecode" -optional = false -python-versions = "*" version = "1.3" - -[[package]] +description = "The most basic Text::Unidecode port" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "toml" +version = "0.10.0" +description = "Python Library for Tom's Obvious, Minimal Language" category = "dev" -description = "Python Library for Tom's Obvious, Minimal Language" -name = "toml" optional = false python-versions = "*" -version = "0.10.0" [[package]] -category = "main" -description = "URI templates" name = "uritemplate" +version = "3.0.1" +description = "URI templates" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "3.0.1" [[package]] -category = "main" -description = "HTTP library with thread-safe connection pooling, file post, and more." name = "urllib3" +version = "1.25.8" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" -version = "1.25.8" [package.extras] brotli = ["brotlipy (>=0.6.0)"] @@ -765,37 +804,46 @@ secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "cer socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] [[package]] -category = "main" -description = "Promises, promises, promises." name = "vine" +version = "1.3.0" +description = "Promises, promises, promises." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.3.0" [[package]] +name = "wcwidth" +version = "0.2.5" +description = "Measures the displayed width of unicode strings in a terminal" category = "main" -description = "Character encoding aliases for legacy web content" -name = "webencodings" optional = false python-versions = "*" -version = "0.5.1" [[package]] +name = "webencodings" +version = "0.5.1" +description = "Character encoding aliases for legacy web content" category = "main" -description = "Backport of pathlib-compatible object wrapper for zip files" -marker = "python_version < \"3.8\"" +optional = false +python-versions = "*" + +[[package]] name = "zipp" +version = "3.1.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "main" optional = false python-versions = ">=3.6" -version = "3.1.0" +marker = "python_version < \"3.8\"" [package.extras] docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] [metadata] -content-hash = "6b207d452b10de2399c4c49118da997dda6ed1bb0437963c3f415ecd3d806fe5" +lock-version = "1.0" python-versions = "^3.7" +content-hash = "cda651cbf92ffc53c6ef09bea6204f5927b5a1bf3feff85bc70fa672e526cc91" [metadata.files] amqp = [ @@ -951,6 +999,9 @@ freezegun = [ {file = "freezegun-0.3.15-py2.py3-none-any.whl", hash = "sha256:82c757a05b7c7ca3e176bfebd7d6779fd9139c7cb4ef969c38a28d74deef89b2"}, {file = "freezegun-0.3.15.tar.gz", hash = "sha256:e2062f2c7f95cc276a834c22f1a17179467176b624cc6f936e8bc3be5535ad1b"}, ] +ftfy = [ + {file = "ftfy-5.8.tar.gz", hash = "sha256:51c7767f8c4b47d291fcef30b9625fb5341c06a31e6a3b627039c706c42f3720"}, +] gunicorn = [ {file = "gunicorn-20.0.4-py2.py3-none-any.whl", hash = "sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c"}, {file = "gunicorn-20.0.4.tar.gz", hash = "sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626"}, @@ -1046,6 +1097,10 @@ markupsafe = [ {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, ] +oauthlib = [ + {file = "oauthlib-3.1.0-py2.py3-none-any.whl", hash = "sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea"}, + {file = "oauthlib-3.1.0.tar.gz", hash = "sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889"}, +] packaging = [ {file = "packaging-20.3-py2.py3-none-any.whl", hash = "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752"}, {file = "packaging-20.3.tar.gz", hash = "sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3"}, @@ -1110,9 +1165,15 @@ pytz = [ {file = "pytz-2019.3.tar.gz", hash = "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"}, ] requests = [ + {file = "requests-2.23.0-py2.7.egg", hash = "sha256:5d2d0ffbb515f39417009a46c14256291061ac01ba8f875b90cad137de83beb4"}, {file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"}, {file = "requests-2.23.0.tar.gz", hash = "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"}, ] +requests-oauthlib = [ + {file = "requests-oauthlib-1.3.0.tar.gz", hash = "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a"}, + {file = "requests_oauthlib-1.3.0-py2.py3-none-any.whl", hash = "sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d"}, + {file = "requests_oauthlib-1.3.0-py3.7.egg", hash = "sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc"}, +] "ruamel.yaml" = [ {file = "ruamel.yaml-0.16.10-py2.py3-none-any.whl", hash = "sha256:0962fd7999e064c4865f96fb1e23079075f4a2a14849bcdc5cdba53a24f9759b"}, {file = "ruamel.yaml-0.16.10.tar.gz", hash = "sha256:099c644a778bf72ffa00524f78dd0b6476bca94a1da344130f4bf3381ce5b954"}, @@ -1179,6 +1240,10 @@ vine = [ {file = "vine-1.3.0-py2.py3-none-any.whl", hash = "sha256:ea4947cc56d1fd6f2095c8d543ee25dad966f78692528e68b4fada11ba3f98af"}, {file = "vine-1.3.0.tar.gz", hash = "sha256:133ee6d7a9016f177ddeaf191c1f58421a1dcc6ee9a42c58b34bed40e1d2cd87"}, ] +wcwidth = [ + {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, + {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, +] webencodings = [ {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, diff --git a/pyproject.toml b/pyproject.toml index bdc34a9..2d400ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,8 @@ gunicorn = "^20.0.4" python-dotenv = "^0.12.0" django = ">=3.0.7" sentry-sdk = "^0.15.1" +ftfy = "^5.8" +requests_oauthlib = "^1.3.0" [tool.poetry.dev-dependencies] factory-boy = "^2.12.0" diff --git a/src/newsreader/accounts/admin.py b/src/newsreader/accounts/admin.py index 49390c7..02d372c 100644 --- a/src/newsreader/accounts/admin.py +++ b/src/newsreader/accounts/admin.py @@ -11,8 +11,18 @@ class UserAdminForm(UserChangeForm): class Meta: widgets = { "email": forms.EmailInput(attrs={"size": "50"}), - "reddit_access_token": forms.TextInput(attrs={"size": "90"}), - "reddit_refresh_token": forms.TextInput(attrs={"size": "90"}), + "reddit_access_token": forms.PasswordInput( + attrs={"size": "90"}, render_value=True + ), + "reddit_refresh_token": forms.PasswordInput( + attrs={"size": "90"}, render_value=True + ), + "twitter_oauth_token": forms.PasswordInput( + attrs={"size": "90"}, render_value=True + ), + "twitter_oauth_token_secret": forms.PasswordInput( + attrs={"size": "90"}, render_value=True + ), } @@ -34,6 +44,10 @@ class UserAdmin(DjangoUserAdmin): _("Reddit settings"), {"fields": ("reddit_access_token", "reddit_refresh_token")}, ), + ( + _("Twitter settings"), + {"fields": ("twitter_oauth_token", "twitter_oauth_token_secret")}, + ), ( _("Permission settings"), {"classes": ("collapse",), "fields": ("is_staff", "is_superuser")}, diff --git a/src/newsreader/accounts/migrations/0011_auto_20200913_2101.py b/src/newsreader/accounts/migrations/0011_auto_20200913_2101.py new file mode 100644 index 0000000..b6a83dd --- /dev/null +++ b/src/newsreader/accounts/migrations/0011_auto_20200913_2101.py @@ -0,0 +1,21 @@ +# Generated by Django 3.0.7 on 2020-09-13 19:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("accounts", "0010_auto_20200603_2230")] + + operations = [ + migrations.AddField( + model_name="user", + name="twitter_oauth_token", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="user", + name="twitter_oauth_token_secret", + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/src/newsreader/accounts/migrations/0012_remove_user_task.py b/src/newsreader/accounts/migrations/0012_remove_user_task.py new file mode 100644 index 0000000..250d300 --- /dev/null +++ b/src/newsreader/accounts/migrations/0012_remove_user_task.py @@ -0,0 +1,10 @@ +# Generated by Django 3.0.7 on 2020-09-26 15:34 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [("accounts", "0011_auto_20200913_2101")] + + operations = [migrations.RemoveField(model_name="user", name="task")] diff --git a/src/newsreader/accounts/models.py b/src/newsreader/accounts/models.py index b8aaa64..2451445 100644 --- a/src/newsreader/accounts/models.py +++ b/src/newsreader/accounts/models.py @@ -1,11 +1,9 @@ -import json - from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import UserManager as DjangoUserManager from django.db import models from django.utils.translation import gettext as _ -from django_celery_beat.models import IntervalSchedule, PeriodicTask +from django_celery_beat.models import PeriodicTask class UserManager(DjangoUserManager): @@ -41,18 +39,12 @@ class UserManager(DjangoUserManager): class User(AbstractUser): email = models.EmailField(_("email address"), unique=True) - task = models.OneToOneField( - PeriodicTask, - on_delete=models.CASCADE, - null=True, - blank=True, - editable=False, - verbose_name="collection task", - ) - reddit_refresh_token = models.CharField(max_length=255, blank=True, null=True) reddit_access_token = models.CharField(max_length=255, blank=True, null=True) + twitter_oauth_token = models.CharField(max_length=255, blank=True, null=True) + twitter_oauth_token_secret = models.CharField(max_length=255, blank=True, null=True) + username = None objects = UserManager() @@ -60,24 +52,12 @@ class User(AbstractUser): USERNAME_FIELD = "email" REQUIRED_FIELDS = [] - def save(self, *args, **kwargs): - super().save(*args, **kwargs) - - if not self.task: - task_interval, _ = IntervalSchedule.objects.get_or_create( - every=1, period=IntervalSchedule.HOURS - ) - - self.task, _ = PeriodicTask.objects.get_or_create( - enabled=True, - interval=task_interval, - name=f"{self.email}-collection-task", - task="FeedTask", - args=json.dumps([self.pk]), - ) - - self.save() - def delete(self, *args, **kwargs): - self.task.delete() + tasks = PeriodicTask.objects.filter(name__contains=self.email) + tasks.delete() + return super().delete(*args, **kwargs) + + @property + def has_twitter_auth(self): + return self.twitter_oauth_token and self.twitter_oauth_token_secret diff --git a/src/newsreader/accounts/templates/accounts/components/settings-form.html b/src/newsreader/accounts/templates/accounts/components/settings-form.html index 7942354..51d4450 100644 --- a/src/newsreader/accounts/templates/accounts/components/settings-form.html +++ b/src/newsreader/accounts/templates/accounts/components/settings-form.html @@ -3,28 +3,15 @@ {% block actions %}
      -
      - {% include "components/form/cancel-button.html" %} -
      -
      {% trans "Change password" %} + + {% trans "Third party integrations" %} + {% include "components/form/confirm-button.html" %} - - {% if reddit_authorization_url %} - - {% trans "Authorize Reddit account" %} - - {% endif %} - - {% if reddit_refresh_url %} - - {% trans "Refresh Reddit access token" %} - - {% endif %}
      {% endblock actions %} diff --git a/src/newsreader/accounts/templates/accounts/views/integrations.html b/src/newsreader/accounts/templates/accounts/views/integrations.html new file mode 100644 index 0000000..4429f02 --- /dev/null +++ b/src/newsreader/accounts/templates/accounts/views/integrations.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block content %} +
      +
      + {% include "components/header/header.html" with title="Integrations" only %} + +
      +

      Reddit

      +
      + {% if reddit_authorization_url %} + + {% trans "Authorize account" %} + + {% else %} + + {% endif %} + + {% if reddit_refresh_url %} + + {% trans "Refresh token" %} + + {% else %} + + {% endif %} + + {% if reddit_revoke_url %} + + {% trans "Deauthorize account" %} + + {% else %} + + {% endif %} +
      +
      + +
      +

      Twitter

      +
      + {% if twitter_auth_url %} + + {% else %} + + {% endif %} + + {% if twitter_revoke_url %} + + {% else %} + + {% endif %} +
      +
      +
      +
      +{% endblock %} diff --git a/src/newsreader/accounts/templates/accounts/views/reddit.html b/src/newsreader/accounts/templates/accounts/views/reddit.html index b393bbe..5d4f539 100644 --- a/src/newsreader/accounts/templates/accounts/views/reddit.html +++ b/src/newsreader/accounts/templates/accounts/views/reddit.html @@ -1,17 +1,20 @@ {% extends "base.html" %} +{% load i18n %} {% block content %} -
      +
      {% if error %} -

      Reddit authorization failed

      +

      {% trans "Reddit authorization failed" %}

      {{ error }}

      {% elif access_token and refresh_token %} -

      Reddit account is linked

      -

      Your reddit account was successfully linked.

      +

      {% trans "Reddit account is linked" %}

      +

      {% trans "Your reddit account was successfully linked." %}

      {% endif %} -

      Return to settings page

      +

      + {% trans "Return to integrations page" %} +

      {% endblock %} diff --git a/src/newsreader/accounts/templates/accounts/views/twitter.html b/src/newsreader/accounts/templates/accounts/views/twitter.html new file mode 100644 index 0000000..e2c51aa --- /dev/null +++ b/src/newsreader/accounts/templates/accounts/views/twitter.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block content %} +
      +
      + {% if error %} +

      {% trans "Twitter authorization failed" %}

      +

      {{ error }}

      + {% elif authorized %} +

      {% trans "Twitter account is linked" %}

      +

      {% trans "Your Twitter account was successfully linked." %}

      + {% endif %} + +

      + {% trans "Return to integrations page" %} +

      +
      +
      +{% endblock %} diff --git a/src/newsreader/accounts/tests/test_integrations.py b/src/newsreader/accounts/tests/test_integrations.py new file mode 100644 index 0000000..cdc9546 --- /dev/null +++ b/src/newsreader/accounts/tests/test_integrations.py @@ -0,0 +1,537 @@ +from unittest.mock import Mock, patch +from urllib.parse import urlencode +from uuid import uuid4 + +from django.core.cache import cache +from django.test import TestCase +from django.urls import reverse +from django.utils.translation import gettext as _ + +from bs4 import BeautifulSoup + +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.exceptions import ( + StreamException, + StreamTooManyException, +) +from newsreader.news.collection.twitter import TWITTER_AUTH_URL + + +class IntegrationsViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(email="test@test.nl", password="test") + self.client.force_login(self.user) + + self.url = reverse("accounts:integrations") + + +class RedditIntegrationsTestCase(IntegrationsViewTestCase): + def test_reddit_authorization(self): + self.user.reddit_refresh_token = None + self.user.save() + + response = self.client.get(self.url) + + soup = BeautifulSoup(response.content, features="lxml") + button = soup.find("a", class_="link button button--reddit") + + self.assertEquals(button.text.strip(), "Authorize account") + + def test_reddit_refresh_token(self): + self.user.reddit_refresh_token = "jadajadajada" + self.user.reddit_access_token = None + self.user.save() + + response = self.client.get(self.url) + + soup = BeautifulSoup(response.content, features="lxml") + button = soup.find("a", class_="link button button--reddit") + + self.assertEquals(button.text.strip(), "Refresh token") + + def test_reddit_revoke(self): + self.user.reddit_refresh_token = "jadajadajada" + self.user.reddit_access_token = None + self.user.save() + + response = self.client.get(self.url) + + soup = BeautifulSoup(response.content, features="lxml") + buttons = soup.find_all("a", class_="link button button--reddit") + + self.assertIn( + "Deauthorize account", [button.text.strip() for button in buttons] + ) + + +class RedditTemplateViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(email="test@test.nl", password="test") + self.client.force_login(self.user) + + self.base_url = reverse("accounts:reddit-template") + self.state = str(uuid4()) + + self.patch = patch("newsreader.news.collection.reddit.post") + self.mocked_post = self.patch.start() + + def tearDown(self): + patch.stopall() + + def test_simple(self): + response = self.client.get(self.base_url) + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Return to integrations page") + + def test_successful_authorization(self): + self.mocked_post.return_value.json.return_value = { + "access_token": "1001010412", + "refresh_token": "134510143", + } + + cache.set(f"{self.user.email}-reddit-auth", self.state) + + params = {"state": self.state, "code": "Valid code"} + url = f"{self.base_url}?{urlencode(params)}" + + response = self.client.get(url) + + self.mocked_post.assert_called_once() + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Your reddit account was successfully linked.") + + self.user.refresh_from_db() + + self.assertEquals(self.user.reddit_access_token, "1001010412") + self.assertEquals(self.user.reddit_refresh_token, "134510143") + + self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), None) + + def test_error(self): + params = {"error": "Denied authorization"} + + url = f"{self.base_url}?{urlencode(params)}" + + response = self.client.get(url) + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Denied authorization") + + def test_invalid_state(self): + cache.set(f"{self.user.email}-reddit-auth", str(uuid4())) + + params = {"code": "Valid code", "state": "Invalid state"} + + url = f"{self.base_url}?{urlencode(params)}" + + response = self.client.get(url) + + self.assertEquals(response.status_code, 200) + self.assertContains( + response, "The saved state for Reddit authorization did not match" + ) + + def test_stream_error(self): + self.mocked_post.side_effect = StreamTooManyException + + cache.set(f"{self.user.email}-reddit-auth", self.state) + + params = {"state": self.state, "code": "Valid code"} + url = f"{self.base_url}?{urlencode(params)}" + + response = self.client.get(url) + + self.mocked_post.assert_called_once() + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Too many requests") + + self.user.refresh_from_db() + + self.assertEquals(self.user.reddit_access_token, None) + self.assertEquals(self.user.reddit_refresh_token, None) + + self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), self.state) + + def test_unexpected_json(self): + self.mocked_post.return_value.json.return_value = {"message": "Happy eastern"} + + cache.set(f"{self.user.email}-reddit-auth", self.state) + + params = {"state": self.state, "code": "Valid code"} + url = f"{self.base_url}?{urlencode(params)}" + + response = self.client.get(url) + + self.mocked_post.assert_called_once() + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Access and refresh token not found in response") + + self.user.refresh_from_db() + + self.assertEquals(self.user.reddit_access_token, None) + self.assertEquals(self.user.reddit_refresh_token, None) + + self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), self.state) + + +class RedditTokenRedirectViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(email="test@test.nl", password="test") + self.client.force_login(self.user) + + self.patch = patch("newsreader.accounts.views.integrations.RedditTokenTask") + self.mocked_task = self.patch.start() + + def tearDown(self): + cache.clear() + + def test_simple(self): + response = self.client.get(reverse("accounts:reddit-refresh")) + + self.assertRedirects(response, reverse("accounts:integrations")) + + self.mocked_task.delay.assert_called_once_with(self.user.pk) + + self.assertEquals(1, cache.get(f"{self.user.email}-reddit-refresh")) + + def test_not_active(self): + cache.set(f"{self.user.email}-reddit-refresh", 1) + + response = self.client.get(reverse("accounts:reddit-refresh")) + + self.assertRedirects(response, reverse("accounts:integrations")) + + self.mocked_task.delay.assert_not_called() + + +class RedditRevokeRedirectViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(email="test@test.nl", password="test") + self.client.force_login(self.user) + + self.patch = patch("newsreader.accounts.views.integrations.revoke_reddit_token") + self.mocked_revoke = self.patch.start() + + def test_simple(self): + self.user.reddit_access_token = "jadajadajada" + self.user.reddit_refresh_token = "jadajadajada" + self.user.save() + + self.mocked_revoke.return_value = True + + response = self.client.get(reverse("accounts:reddit-revoke")) + + self.assertRedirects(response, reverse("accounts:integrations")) + + self.mocked_revoke.assert_called_once_with(self.user) + + self.user.refresh_from_db() + + self.assertEquals(self.user.reddit_access_token, None) + self.assertEquals(self.user.reddit_refresh_token, None) + + def test_no_refresh_token(self): + self.user.reddit_refresh_token = None + self.user.save() + + response = self.client.get(reverse("accounts:reddit-revoke")) + + self.assertRedirects(response, reverse("accounts:integrations")) + + self.mocked_revoke.assert_not_called() + + def test_unsuccessful_response(self): + self.user.reddit_access_token = "jadajadajada" + self.user.reddit_refresh_token = "jadajadajada" + self.user.save() + + self.mocked_revoke.return_value = False + + response = self.client.get(reverse("accounts:reddit-revoke")) + + self.assertRedirects(response, reverse("accounts:integrations")) + + self.user.refresh_from_db() + + self.assertEquals(self.user.reddit_access_token, "jadajadajada") + self.assertEquals(self.user.reddit_refresh_token, "jadajadajada") + + def test_stream_exception(self): + self.user.reddit_access_token = "jadajadajada" + self.user.reddit_refresh_token = "jadajadajada" + self.user.save() + + self.mocked_revoke.side_effect = StreamException + + response = self.client.get(reverse("accounts:reddit-revoke")) + + self.assertRedirects(response, reverse("accounts:integrations")) + + self.user.refresh_from_db() + + self.assertEquals(self.user.reddit_access_token, "jadajadajada") + self.assertEquals(self.user.reddit_refresh_token, "jadajadajada") + + +class TwitterRevokeRedirectView(TestCase): + def setUp(self): + self.user = UserFactory(email="test@test.nl", password="test") + self.client.force_login(self.user) + + self.patch = patch("newsreader.accounts.views.integrations.post") + self.mocked_post = self.patch.start() + + def tearDown(self): + patch.stopall() + + def test_simple(self): + self.user.twitter_oauth_token = "jadajadajada" + self.user.twitter_oauth_token_secret = "jadajadajada" + self.user.save() + + response = self.client.get(reverse("accounts:twitter-revoke")) + + self.assertRedirects(response, reverse("accounts:integrations")) + + self.user.refresh_from_db() + + self.assertIsNone(self.user.twitter_oauth_token) + self.assertIsNone(self.user.twitter_oauth_token_secret) + + def test_no_authorized_account(self): + self.user.twitter_oauth_token = None + self.user.twitter_oauth_token_secret = None + self.user.save() + + response = self.client.get(reverse("accounts:twitter-revoke")) + + self.assertRedirects(response, reverse("accounts:integrations")) + + self.mocked_post.assert_not_called() + + def test_stream_exception(self): + self.user.twitter_oauth_token = "jadajadajada" + self.user.twitter_oauth_token_secret = "jadajadajada" + self.user.save() + + self.mocked_post.side_effect = StreamException + + response = self.client.get(reverse("accounts:twitter-revoke")) + + self.assertRedirects(response, reverse("accounts:integrations")) + + self.user.refresh_from_db() + + self.assertEquals(self.user.twitter_oauth_token, "jadajadajada") + self.assertEquals(self.user.twitter_oauth_token_secret, "jadajadajada") + + +class TwitterAuthRedirectViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(email="test@test.nl", password="test") + self.client.force_login(self.user) + + self.patch = patch("newsreader.accounts.views.integrations.post") + self.mocked_post = self.patch.start() + + def tearDown(self): + cache.clear() + + def test_simple(self): + self.mocked_post.return_value = Mock( + text="oauth_token=foo&oauth_token_secret=bar" + ) + + response = self.client.get(reverse("accounts:twitter-auth")) + + self.assertRedirects( + response, + f"{TWITTER_AUTH_URL}/?oauth_token=foo", + fetch_redirect_response=False, + ) + + cached_token = cache.get(f"twitter-{self.user.email}-token") + cached_secret = cache.get(f"twitter-{self.user.email}-secret") + + self.assertEquals(cached_token, "foo") + self.assertEquals(cached_secret, "bar") + + def test_stream_exception(self): + self.mocked_post.side_effect = StreamException + + response = self.client.get(reverse("accounts:twitter-auth")) + + self.assertRedirects(response, reverse("accounts:integrations")) + + cached_token = cache.get(f"twitter-{self.user.email}-token") + cached_secret = cache.get(f"twitter-{self.user.email}-secret") + + self.assertIsNone(cached_token) + self.assertIsNone(cached_secret) + + def test_unexpected_contents(self): + self.mocked_post.return_value = Mock(text="foo=bar&oauth_token_secret=bar") + + response = self.client.get(reverse("accounts:twitter-auth")) + + self.assertRedirects(response, reverse("accounts:integrations")) + + cached_token = cache.get(f"twitter-{self.user.email}-token") + cached_secret = cache.get(f"twitter-{self.user.email}-secret") + + self.assertIsNone(cached_token) + self.assertIsNone(cached_secret) + + +class TwitterTemplateViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(email="test@test.nl", password="test") + self.client.force_login(self.user) + + self.patch = patch("newsreader.accounts.views.integrations.post") + self.mocked_post = self.patch.start() + + def tearDown(self): + cache.clear() + + def test_simple(self): + cache.set_many( + { + f"twitter-{self.user.email}-token": "foo", + f"twitter-{self.user.email}-secret": "bar", + } + ) + + params = {"denied": "", "oauth_token": "foo", "oauth_verifier": "barfoo"} + + self.mocked_post.return_value = Mock( + text="oauth_token=realtoken&oauth_token_secret=realsecret" + ) + + response = self.client.get( + f"{reverse('accounts:twitter-template')}?{urlencode(params)}" + ) + + self.assertContains(response, _("Twitter account is linked")) + + self.user.refresh_from_db() + + self.assertEquals(self.user.twitter_oauth_token, "realtoken") + self.assertEquals(self.user.twitter_oauth_token_secret, "realsecret") + + self.assertIsNone(cache.get(f"twitter-{self.user.email}-token")) + self.assertIsNone(cache.get(f"twitter-{self.user.email}-secret")) + + def test_denied(self): + params = {"denied": "true", "oauth_token": "foo", "oauth_verifier": "barfoo"} + + response = self.client.get( + f"{reverse('accounts:twitter-template')}?{urlencode(params)}" + ) + + self.assertContains(response, _("Twitter authorization failed")) + + self.user.refresh_from_db() + + self.assertIsNone(self.user.twitter_oauth_token) + self.assertIsNone(self.user.twitter_oauth_token_secret) + + self.mocked_post.assert_not_called() + + def test_mismatched_token(self): + cache.set_many( + { + f"twitter-{self.user.email}-token": "foo", + f"twitter-{self.user.email}-secret": "bar", + } + ) + + params = {"denied": "", "oauth_token": "boo", "oauth_verifier": "barfoo"} + + response = self.client.get( + f"{reverse('accounts:twitter-template')}?{urlencode(params)}" + ) + + self.assertContains(response, _("OAuth tokens failed to match")) + + self.user.refresh_from_db() + + self.assertIsNone(self.user.twitter_oauth_token) + self.assertIsNone(self.user.twitter_oauth_token_secret) + + self.mocked_post.assert_not_called() + + def test_missing_secret(self): + cache.set_many({f"twitter-{self.user.email}-token": "foo"}) + + params = {"denied": "", "oauth_token": "foo", "oauth_verifier": "barfoo"} + + response = self.client.get( + f"{reverse('accounts:twitter-template')}?{urlencode(params)}" + ) + + self.assertContains(response, _("No matching tokens found for this user")) + + self.user.refresh_from_db() + + self.assertIsNone(self.user.twitter_oauth_token_secret) + + self.mocked_post.assert_not_called() + + def test_stream_exception(self): + cache.set_many( + { + f"twitter-{self.user.email}-token": "foo", + f"twitter-{self.user.email}-secret": "bar", + } + ) + + params = {"denied": "", "oauth_token": "foo", "oauth_verifier": "barfoo"} + + self.mocked_post.side_effect = StreamException + + response = self.client.get( + f"{reverse('accounts:twitter-template')}?{urlencode(params)}" + ) + + self.assertContains(response, _("Failed requesting access token")) + + self.user.refresh_from_db() + + self.assertIsNone(self.user.twitter_oauth_token) + self.assertIsNone(self.user.twitter_oauth_token_secret) + + self.assertIsNotNone(cache.get(f"twitter-{self.user.email}-token")) + self.assertIsNotNone(cache.get(f"twitter-{self.user.email}-secret")) + + def test_unexpected_contents(self): + cache.set_many( + { + f"twitter-{self.user.email}-token": "foo", + f"twitter-{self.user.email}-secret": "bar", + } + ) + + params = {"denied": "", "oauth_token": "foo", "oauth_verifier": "barfoo"} + + self.mocked_post.return_value = Mock( + text="foobar=boo&oauth_token_secret=realsecret" + ) + + response = self.client.get( + f"{reverse('accounts:twitter-template')}?{urlencode(params)}" + ) + + self.assertContains(response, _("No credentials found in Twitter response")) + + self.user.refresh_from_db() + + self.assertIsNone(self.user.twitter_oauth_token) + self.assertIsNone(self.user.twitter_oauth_token_secret) + + self.assertIsNotNone(cache.get(f"twitter-{self.user.email}-token")) + self.assertIsNotNone(cache.get(f"twitter-{self.user.email}-secret")) diff --git a/src/newsreader/accounts/tests/test_settings.py b/src/newsreader/accounts/tests/test_settings.py index d093ea4..42db736 100644 --- a/src/newsreader/accounts/tests/test_settings.py +++ b/src/newsreader/accounts/tests/test_settings.py @@ -1,14 +1,8 @@ -from unittest.mock import patch -from urllib.parse import urlencode -from uuid import uuid4 - -from django.core.cache import cache from django.test import TestCase from django.urls import reverse from newsreader.accounts.models import User from newsreader.accounts.tests.factories import UserFactory -from newsreader.news.collection.exceptions import StreamTooManyException class SettingsViewTestCase(TestCase): @@ -22,7 +16,6 @@ class SettingsViewTestCase(TestCase): response = self.client.get(self.url) self.assertEquals(response.status_code, 200) - self.assertContains(response, "Authorize Reddit account") def test_user_credential_change(self): response = self.client.post( @@ -36,126 +29,3 @@ class SettingsViewTestCase(TestCase): self.assertEquals(user.first_name, "First name") self.assertEquals(user.last_name, "Last name") - - def test_linked_reddit_account(self): - self.user.reddit_refresh_token = "test" - self.user.save() - - response = self.client.get(self.url) - - self.assertEquals(response.status_code, 200) - self.assertNotContains(response, "Authorize Reddit account") - - -class RedditTemplateViewTestCase(TestCase): - def setUp(self): - self.user = UserFactory(email="test@test.nl", password="test") - self.client.force_login(self.user) - - self.base_url = reverse("accounts:reddit-template") - self.state = str(uuid4()) - - self.patch = patch("newsreader.news.collection.reddit.post") - self.mocked_post = self.patch.start() - - def tearDown(self): - patch.stopall() - - def test_simple(self): - response = self.client.get(self.base_url) - - self.assertEquals(response.status_code, 200) - self.assertContains(response, "Return to settings page") - - def test_successful_authorization(self): - self.mocked_post.return_value.json.return_value = { - "access_token": "1001010412", - "refresh_token": "134510143", - } - - cache.set(f"{self.user.email}-reddit-auth", self.state) - - params = {"state": self.state, "code": "Valid code"} - url = f"{self.base_url}?{urlencode(params)}" - - response = self.client.get(url) - - self.mocked_post.assert_called_once() - - self.assertEquals(response.status_code, 200) - self.assertContains(response, "Your reddit account was successfully linked.") - - self.user.refresh_from_db() - - self.assertEquals(self.user.reddit_access_token, "1001010412") - self.assertEquals(self.user.reddit_refresh_token, "134510143") - - self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), None) - - def test_error(self): - params = {"error": "Denied authorization"} - - url = f"{self.base_url}?{urlencode(params)}" - - response = self.client.get(url) - - self.assertEquals(response.status_code, 200) - self.assertContains(response, "Denied authorization") - - def test_invalid_state(self): - cache.set(f"{self.user.email}-reddit-auth", str(uuid4())) - - params = {"code": "Valid code", "state": "Invalid state"} - - url = f"{self.base_url}?{urlencode(params)}" - - response = self.client.get(url) - - self.assertEquals(response.status_code, 200) - self.assertContains( - response, "The saved state for Reddit authorization did not match" - ) - - def test_stream_error(self): - self.mocked_post.side_effect = StreamTooManyException - - cache.set(f"{self.user.email}-reddit-auth", self.state) - - params = {"state": self.state, "code": "Valid code"} - url = f"{self.base_url}?{urlencode(params)}" - - response = self.client.get(url) - - self.mocked_post.assert_called_once() - - self.assertEquals(response.status_code, 200) - self.assertContains(response, "Too many requests") - - self.user.refresh_from_db() - - self.assertEquals(self.user.reddit_access_token, None) - self.assertEquals(self.user.reddit_refresh_token, None) - - self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), self.state) - - def test_unexpected_json(self): - self.mocked_post.return_value.json.return_value = {"message": "Happy eastern"} - - cache.set(f"{self.user.email}-reddit-auth", self.state) - - params = {"state": self.state, "code": "Valid code"} - url = f"{self.base_url}?{urlencode(params)}" - - response = self.client.get(url) - - self.mocked_post.assert_called_once() - - self.assertEquals(response.status_code, 200) - self.assertContains(response, "Access and refresh token not found in response") - - self.user.refresh_from_db() - - self.assertEquals(self.user.reddit_access_token, None) - self.assertEquals(self.user.reddit_refresh_token, None) - - self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), self.state) diff --git a/src/newsreader/accounts/tests/tests.py b/src/newsreader/accounts/tests/tests.py index e28dbd3..9f6a20f 100644 --- a/src/newsreader/accounts/tests/tests.py +++ b/src/newsreader/accounts/tests/tests.py @@ -1,22 +1,24 @@ from django.test import TestCase -from django_celery_beat.models import PeriodicTask +from django_celery_beat.models import IntervalSchedule, PeriodicTask -from newsreader.accounts.models import User +from newsreader.accounts.tests.factories import UserFactory class UserTestCase(TestCase): - def test_task_is_created(self): - user = User.objects.create(email="durp@burp.nl", task=None) - task = PeriodicTask.objects.get(name=f"{user.email}-collection-task") - - user.refresh_from_db() - - self.assertEquals(task, user.task) - self.assertEquals(PeriodicTask.objects.count(), 1) - def test_task_is_deleted(self): - user = User.objects.create(email="durp@burp.nl", task=None) + user = UserFactory(email="durp@burp.nl") + + interval = IntervalSchedule.objects.create( + every=1, period=IntervalSchedule.HOURS + ) + PeriodicTask.objects.create( + name=f"{user.email}-feed", task="FeedTask", interval=interval + ) + PeriodicTask.objects.create( + name=f"{user.email}-timeline", task="TwitterTimelineTask", interval=interval + ) + user.delete() self.assertEquals(PeriodicTask.objects.count(), 0) diff --git a/src/newsreader/accounts/urls.py b/src/newsreader/accounts/urls.py index 672cf6d..3cdd1b1 100644 --- a/src/newsreader/accounts/urls.py +++ b/src/newsreader/accounts/urls.py @@ -5,6 +5,7 @@ from newsreader.accounts.views import ( ActivationCompleteView, ActivationResendView, ActivationView, + IntegrationsView, LoginView, LogoutView, PasswordChangeView, @@ -12,18 +13,24 @@ from newsreader.accounts.views import ( PasswordResetConfirmView, PasswordResetDoneView, PasswordResetView, + RedditRevokeRedirectView, RedditTemplateView, RedditTokenRedirectView, RegistrationClosedView, RegistrationCompleteView, RegistrationView, SettingsView, + TwitterAuthRedirectView, + TwitterRevokeRedirectView, + TwitterTemplateView, ) urlpatterns = [ + # Auth path("login/", LoginView.as_view(), name="login"), path("logout/", LogoutView.as_view(), name="logout"), + # Register path("register/", RegistrationView.as_view(), name="register"), path( "register/complete/", @@ -41,6 +48,7 @@ urlpatterns = [ ActivationView.as_view(), name="activate", ), + # Password path("password-reset/", PasswordResetView.as_view(), name="password-reset"), path( "password-reset/done/", @@ -62,15 +70,42 @@ urlpatterns = [ login_required(PasswordChangeView.as_view()), name="password-change", ), - path("settings/", login_required(SettingsView.as_view()), name="settings"), + # Integrations path( - "settings/reddit/callback/", + "settings/integrations/reddit/callback/", login_required(RedditTemplateView.as_view()), name="reddit-template", ), path( - "settings/reddit/refresh/", + "settings/integrations/reddit/refresh/", login_required(RedditTokenRedirectView.as_view()), name="reddit-refresh", ), + path( + "settings/integrations/reddit/revoke/", + login_required(RedditRevokeRedirectView.as_view()), + name="reddit-revoke", + ), + path( + "settings/integrations/twitter/auth/", + login_required(TwitterAuthRedirectView.as_view()), + name="twitter-auth", + ), + path( + "settings/integrations/twitter/callback/", + login_required(TwitterTemplateView.as_view()), + name="twitter-template", + ), + path( + "settings/integrations/twitter/revoke/", + login_required(TwitterRevokeRedirectView.as_view()), + name="twitter-revoke", + ), + path( + "settings/integrations", + login_required(IntegrationsView.as_view()), + name="integrations", + ), + # Settings + path("settings/", login_required(SettingsView.as_view()), name="settings"), ] diff --git a/src/newsreader/accounts/views.py b/src/newsreader/accounts/views.py deleted file mode 100644 index 4f982a9..0000000 --- a/src/newsreader/accounts/views.py +++ /dev/null @@ -1,210 +0,0 @@ -from django.contrib import messages -from django.contrib.auth import views as django_views -from django.core.cache import cache -from django.shortcuts import render -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from django.views.generic import RedirectView, TemplateView -from django.views.generic.edit import FormView, ModelFormMixin - -from registration.backends.default import views as registration_views - -from newsreader.accounts.forms import UserSettingsForm -from newsreader.accounts.models import User -from newsreader.news.collection.exceptions import StreamException -from newsreader.news.collection.reddit import ( - get_reddit_access_token, - get_reddit_authorization_url, -) -from newsreader.news.collection.tasks import RedditTokenTask - - -class LoginView(django_views.LoginView): - template_name = "accounts/views/login.html" - success_url = reverse_lazy("index") - - -class LogoutView(django_views.LogoutView): - next_page = reverse_lazy("accounts:login") - - -# RegistrationView shows a registration form and sends the email -# RegistrationCompleteView shows after filling in the registration form -# ActivationView is send within the activation email and activates the account -# ActivationCompleteView shows the success screen when activation was succesful -# ActivationResendView can be used when activation links are expired -# RegistrationClosedView shows when registration is disabled -class RegistrationView(registration_views.RegistrationView): - disallowed_url = reverse_lazy("accounts:register-closed") - template_name = "registration/registration_form.html" - success_url = reverse_lazy("accounts:register-complete") - - -class RegistrationCompleteView(TemplateView): - template_name = "registration/registration_complete.html" - - -class RegistrationClosedView(TemplateView): - template_name = "registration/registration_closed.html" - - -# Redirects or renders failed activation template -class ActivationView(registration_views.ActivationView): - template_name = "registration/activation_failure.html" - - def get_success_url(self, user): - return ("accounts:activate-complete", (), {}) - - -class ActivationCompleteView(TemplateView): - template_name = "registration/activation_complete.html" - - -# Renders activation form resend or resend_activation_complete -class ActivationResendView(registration_views.ResendActivationView): - template_name = "registration/activation_resend_form.html" - - def render_form_submitted_template(self, form): - """ - Renders resend activation complete template with the submitted email. - - """ - email = form.cleaned_data["email"] - context = {"email": email} - - return render( - self.request, "registration/activation_resend_complete.html", context - ) - - -# PasswordResetView sends the mail -# PasswordResetDoneView shows a success message for the above -# PasswordResetConfirmView checks the link the user clicked and -# prompts for a new password -# PasswordResetCompleteView shows a success message for the above -class PasswordResetView(django_views.PasswordResetView): - template_name = "password-reset/password-reset.html" - subject_template_name = "password-reset/password-reset-subject.txt" - email_template_name = "password-reset/password-reset-email.html" - success_url = reverse_lazy("accounts:password-reset-done") - - -class PasswordResetDoneView(django_views.PasswordResetDoneView): - template_name = "password-reset/password-reset-done.html" - - -class PasswordResetConfirmView(django_views.PasswordResetConfirmView): - template_name = "password-reset/password-reset-confirm.html" - success_url = reverse_lazy("accounts:password-reset-complete") - - -class PasswordResetCompleteView(django_views.PasswordResetCompleteView): - template_name = "password-reset/password-reset-complete.html" - - -class PasswordChangeView(django_views.PasswordChangeView): - template_name = "accounts/views/password-change.html" - success_url = reverse_lazy("accounts:settings") - - -class SettingsView(ModelFormMixin, FormView): - template_name = "accounts/views/settings.html" - success_url = reverse_lazy("accounts:settings") - form_class = UserSettingsForm - model = User - - def get(self, request, *args, **kwargs): - self.object = self.get_object() - return super().get(request, *args, **kwargs) - - def get_object(self, **kwargs): - return self.request.user - - def get_context_data(self, **kwargs): - user = self.request.user - - reddit_authorization_url = None - reddit_refresh_url = None - reddit_task_active = cache.get(f"{user.email}-reddit-refresh") - - if ( - user.reddit_refresh_token - and not user.reddit_access_token - and not reddit_task_active - ): - reddit_refresh_url = reverse_lazy("accounts:reddit-refresh") - - if not user.reddit_refresh_token: - reddit_authorization_url = get_reddit_authorization_url(user) - - return { - **super().get_context_data(**kwargs), - "reddit_authorization_url": reddit_authorization_url, - "reddit_refresh_url": reddit_refresh_url, - } - - def get_form_kwargs(self): - return {**super().get_form_kwargs(), "instance": self.request.user} - - -class RedditTemplateView(TemplateView): - template_name = "accounts/views/reddit.html" - - def get(self, request, *args, **kwargs): - context = self.get_context_data(**kwargs) - - error = request.GET.get("error", None) - state = request.GET.get("state", None) - code = request.GET.get("code", None) - - if error: - return self.render_to_response({**context, "error": error}) - - if not code or not state: - return self.render_to_response(context) - - cached_state = cache.get(f"{request.user.email}-reddit-auth") - - if state != cached_state: - return self.render_to_response( - { - **context, - "error": "The saved state for Reddit authorization did not match", - } - ) - - try: - access_token, refresh_token = get_reddit_access_token(code, request.user) - - return self.render_to_response( - { - **context, - "access_token": access_token, - "refresh_token": refresh_token, - } - ) - except StreamException as e: - return self.render_to_response({**context, "error": str(e)}) - except KeyError: - return self.render_to_response( - {**context, "error": "Access and refresh token not found in response"} - ) - - -class RedditTokenRedirectView(RedirectView): - url = reverse_lazy("accounts:settings") - - def get(self, request, *args, **kwargs): - response = super().get(request, *args, **kwargs) - - user = request.user - task_active = cache.get(f"{user.email}-reddit-refresh") - - if not task_active: - RedditTokenTask.delay(user.pk) - messages.success(request, _("Access token is being retrieved")) - cache.set(f"{user.email}-reddit-refresh", 1, 300) - return response - - messages.error(request, _("Unable to retrieve token")) - return response diff --git a/src/newsreader/accounts/views/__init__.py b/src/newsreader/accounts/views/__init__.py new file mode 100644 index 0000000..81dd1fc --- /dev/null +++ b/src/newsreader/accounts/views/__init__.py @@ -0,0 +1,26 @@ +from newsreader.accounts.views.auth import LoginView, LogoutView +from newsreader.accounts.views.integrations import ( + IntegrationsView, + RedditRevokeRedirectView, + RedditTemplateView, + RedditTokenRedirectView, + TwitterAuthRedirectView, + TwitterRevokeRedirectView, + TwitterTemplateView, +) +from newsreader.accounts.views.password import ( + PasswordChangeView, + PasswordResetCompleteView, + PasswordResetConfirmView, + PasswordResetDoneView, + PasswordResetView, +) +from newsreader.accounts.views.registration import ( + ActivationCompleteView, + ActivationResendView, + ActivationView, + RegistrationClosedView, + RegistrationCompleteView, + RegistrationView, +) +from newsreader.accounts.views.settings import SettingsView diff --git a/src/newsreader/accounts/views/auth.py b/src/newsreader/accounts/views/auth.py new file mode 100644 index 0000000..0663768 --- /dev/null +++ b/src/newsreader/accounts/views/auth.py @@ -0,0 +1,11 @@ +from django.contrib.auth import views as django_views +from django.urls import reverse_lazy + + +class LoginView(django_views.LoginView): + template_name = "accounts/views/login.html" + success_url = reverse_lazy("index") + + +class LogoutView(django_views.LogoutView): + next_page = reverse_lazy("accounts:login") diff --git a/src/newsreader/accounts/views/integrations.py b/src/newsreader/accounts/views/integrations.py new file mode 100644 index 0000000..62d71fc --- /dev/null +++ b/src/newsreader/accounts/views/integrations.py @@ -0,0 +1,343 @@ +import logging + +from urllib.parse import parse_qs, urlencode + +from django.conf import settings +from django.contrib import messages +from django.core.cache import cache +from django.shortcuts import redirect +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from django.views.generic import RedirectView, TemplateView + +from requests_oauthlib import OAuth1 as OAuth + +from newsreader.news.collection.exceptions import StreamException +from newsreader.news.collection.reddit import ( + get_reddit_access_token, + get_reddit_authorization_url, + revoke_reddit_token, +) +from newsreader.news.collection.tasks import RedditTokenTask +from newsreader.news.collection.twitter import ( + TWITTER_ACCESS_TOKEN_URL, + TWITTER_AUTH_URL, + TWITTER_REQUEST_TOKEN_URL, + TWITTER_REVOKE_URL, +) +from newsreader.news.collection.utils import post + + +logger = logging.getLogger(__name__) + + +class IntegrationsView(TemplateView): + template_name = "accounts/views/integrations.html" + + def get_context_data(self, **kwargs): + return { + **super().get_context_data(**kwargs), + **self.get_reddit_context(**kwargs), + **self.get_twitter_context(**kwargs), + } + + def get_reddit_context(self, **kwargs): + user = self.request.user + reddit_authorization_url = None + reddit_refresh_url = None + + reddit_task_active = cache.get(f"{user.email}-reddit-refresh") + + if ( + user.reddit_refresh_token + and not user.reddit_access_token + and not reddit_task_active + ): + reddit_refresh_url = reverse_lazy("accounts:reddit-refresh") + + if not user.reddit_refresh_token: + reddit_authorization_url = get_reddit_authorization_url(user) + + return { + "reddit_authorization_url": reddit_authorization_url, + "reddit_refresh_url": reddit_refresh_url, + "reddit_revoke_url": ( + reverse_lazy("accounts:reddit-revoke") + if not reddit_authorization_url + else None + ), + } + + def get_twitter_context(self, **kwargs): + twitter_revoke_url = None + + if self.request.user.has_twitter_auth: + twitter_revoke_url = reverse_lazy("accounts:twitter-revoke") + + return { + "twitter_auth_url": reverse_lazy("accounts:twitter-auth"), + "twitter_revoke_url": twitter_revoke_url, + } + + +class RedditTemplateView(TemplateView): + template_name = "accounts/views/reddit.html" + + def get(self, request, *args, **kwargs): + context = self.get_context_data(**kwargs) + + error = request.GET.get("error", None) + state = request.GET.get("state", None) + code = request.GET.get("code", None) + + if error: + return self.render_to_response({**context, "error": error}) + + if not code or not state: + return self.render_to_response(context) + + cached_state = cache.get(f"{request.user.email}-reddit-auth") + + if state != cached_state: + return self.render_to_response( + { + **context, + "error": _( + "The saved state for Reddit authorization did not match" + ), + } + ) + + try: + access_token, refresh_token = get_reddit_access_token(code, request.user) + + return self.render_to_response( + { + **context, + "access_token": access_token, + "refresh_token": refresh_token, + } + ) + except StreamException as e: + return self.render_to_response({**context, "error": str(e)}) + except KeyError: + return self.render_to_response( + { + **context, + "error": _("Access and refresh token not found in response"), + } + ) + + +class RedditTokenRedirectView(RedirectView): + url = reverse_lazy("accounts:integrations") + + def get(self, request, *args, **kwargs): + response = super().get(request, *args, **kwargs) + + user = request.user + task_active = cache.get(f"{user.email}-reddit-refresh") + + if not task_active: + RedditTokenTask.delay(user.pk) + messages.success(request, _("Access token is being retrieved")) + cache.set(f"{user.email}-reddit-refresh", 1, 300) + return response + + messages.error(request, _("Unable to retrieve token")) + return response + + +class RedditRevokeRedirectView(RedirectView): + url = reverse_lazy("accounts:integrations") + + def get(self, request, *args, **kwargs): + response = super().get(request, *args, **kwargs) + + user = request.user + + if not user.reddit_refresh_token: + messages.error(request, _("No reddit account is linked to this account")) + return response + + try: + is_revoked = revoke_reddit_token(user) + except StreamException: + logger.exception(f"Unable to revoke reddit token for {user.pk}") + + messages.error(request, _("Unable to revoke reddit token")) + return response + + if not is_revoked: + messages.error(request, _("Unable to revoke reddit token")) + return response + + user.reddit_access_token = None + user.reddit_refresh_token = None + user.save() + + messages.success(request, _("Reddit account deathorized")) + return response + + +class TwitterRevokeRedirectView(RedirectView): + url = reverse_lazy("accounts:integrations") + + def get(self, request, *args, **kwargs): + if not request.user.has_twitter_auth: + messages.error(request, _("No twitter credentials found")) + return super().get(request, *args, **kwargs) + + oauth = OAuth( + settings.TWITTER_CONSUMER_ID, + client_secret=settings.TWITTER_CONSUMER_SECRET, + resource_owner_key=request.user.twitter_oauth_token, + resource_owner_secret=request.user.twitter_oauth_token_secret, + ) + + try: + post(TWITTER_REVOKE_URL, auth=oauth) + except StreamException: + logger.exception("Failed revoking Twitter account") + + messages.error(request, _("Unable revoke Twitter account")) + return super().get(request, *args, **kwargs) + + request.user.twitter_oauth_token = None + request.user.twitter_oauth_token_secret = None + request.user.save() + + messages.success(request, _("Twitter account revoked")) + return super().get(request, *args, **kwargs) + + +class TwitterAuthRedirectView(RedirectView): + url = reverse_lazy("accounts:integrations") + + def get(self, request, *args, **kwargs): + oauth = OAuth( + settings.TWITTER_CONSUMER_ID, + client_secret=settings.TWITTER_CONSUMER_SECRET, + callback_uri=settings.TWITTER_REDIRECT_URL, + ) + + try: + response = post(TWITTER_REQUEST_TOKEN_URL, auth=oauth) + except StreamException: + logger.exception("Failed requesting Twitter authentication token") + + messages.error(request, _("Unable to retrieve initial Twitter token")) + return super().get(request, *args, **kwargs) + + params = parse_qs(response.text) + + try: + request_oauth_token = params["oauth_token"][0] + request_oauth_secret = params["oauth_token_secret"][0] + except KeyError: + logger.exception("No credentials found in response") + + messages.error(request, _("Unable to retrieve initial Twitter token")) + return super().get(request, *args, **kwargs) + + cache.set_many( + { + f"twitter-{request.user.email}-token": request_oauth_token, + f"twitter-{request.user.email}-secret": request_oauth_secret, + } + ) + + request_params = urlencode({"oauth_token": request_oauth_token}) + return redirect(f"{TWITTER_AUTH_URL}/?{request_params}") + + +class TwitterTemplateView(TemplateView): + template_name = "accounts/views/twitter.html" + + def get(self, request, *args, **kwargs): + context = self.get_context_data(**kwargs) + + denied = request.GET.get("denied", False) + oauth_token = request.GET.get("oauth_token") + oauth_verifier = request.GET.get("oauth_verifier") + + if denied: + return self.render_to_response( + { + **context, + "error": _("Twitter authorization failed"), + "authorized": False, + } + ) + + cached_token = cache.get(f"twitter-{request.user.email}-token") + + if oauth_token != cached_token: + return self.render_to_response( + { + **context, + "error": _("OAuth tokens failed to match"), + "authorized": False, + } + ) + + cached_secret = cache.get(f"twitter-{request.user.email}-secret") + + if not cached_token or not cached_secret: + return self.render_to_response( + { + **context, + "error": _("No matching tokens found for this user"), + "authorized": False, + } + ) + + oauth = OAuth( + settings.TWITTER_CONSUMER_ID, + client_secret=settings.TWITTER_CONSUMER_SECRET, + resource_owner_key=cached_token, + resource_owner_secret=cached_secret, + verifier=oauth_verifier, + ) + + try: + response = post(TWITTER_ACCESS_TOKEN_URL, auth=oauth) + except StreamException: + logger.exception("Failed requesting Twitter access token") + + return self.render_to_response( + { + **context, + "error": _("Failed requesting access token"), + "authorized": False, + } + ) + + params = parse_qs(response.text) + + try: + oauth_token = params["oauth_token"][0] + oauth_secret = params["oauth_token_secret"][0] + except KeyError: + logger.exception("No credentials in Twitter response") + + return self.render_to_response( + { + **context, + "error": _("No credentials found in Twitter response"), + "authorized": False, + } + ) + + request.user.twitter_oauth_token = oauth_token + request.user.twitter_oauth_token_secret = oauth_secret + request.user.save() + + cache.delete_many( + [ + f"twitter-{request.user.email}-token", + f"twitter-{request.user.email}-secret", + ] + ) + + return self.render_to_response({**context, "error": None, "authorized": True}) diff --git a/src/newsreader/accounts/views/password.py b/src/newsreader/accounts/views/password.py new file mode 100644 index 0000000..e9e0aa3 --- /dev/null +++ b/src/newsreader/accounts/views/password.py @@ -0,0 +1,37 @@ +from django.contrib.auth import views as django_views +from django.urls import reverse_lazy + +from newsreader.news.collection.reddit import ( + get_reddit_access_token, + get_reddit_authorization_url, +) + + +# PasswordResetView sends the mail +# PasswordResetDoneView shows a success message for the above +# PasswordResetConfirmView checks the link the user clicked and +# prompts for a new password +# PasswordResetCompleteView shows a success message for the above +class PasswordResetView(django_views.PasswordResetView): + template_name = "password-reset/password-reset.html" + subject_template_name = "password-reset/password-reset-subject.txt" + email_template_name = "password-reset/password-reset-email.html" + success_url = reverse_lazy("accounts:password-reset-done") + + +class PasswordResetDoneView(django_views.PasswordResetDoneView): + template_name = "password-reset/password-reset-done.html" + + +class PasswordResetConfirmView(django_views.PasswordResetConfirmView): + template_name = "password-reset/password-reset-confirm.html" + success_url = reverse_lazy("accounts:password-reset-complete") + + +class PasswordResetCompleteView(django_views.PasswordResetCompleteView): + template_name = "password-reset/password-reset-complete.html" + + +class PasswordChangeView(django_views.PasswordChangeView): + template_name = "accounts/views/password-change.html" + success_url = reverse_lazy("accounts:settings") diff --git a/src/newsreader/accounts/views/registration.py b/src/newsreader/accounts/views/registration.py new file mode 100644 index 0000000..597aa9a --- /dev/null +++ b/src/newsreader/accounts/views/registration.py @@ -0,0 +1,59 @@ +from django.shortcuts import render +from django.urls import reverse_lazy +from django.views.generic import TemplateView + +from registration.backends.default import views as registration_views + +from newsreader.news.collection.reddit import ( + get_reddit_access_token, + get_reddit_authorization_url, +) + + +# RegistrationView shows a registration form and sends the email +# RegistrationCompleteView shows after filling in the registration form +# ActivationView is send within the activation email and activates the account +# ActivationCompleteView shows the success screen when activation was succesful +# ActivationResendView can be used when activation links are expired +# RegistrationClosedView shows when registration is disabled +class RegistrationView(registration_views.RegistrationView): + disallowed_url = reverse_lazy("accounts:register-closed") + template_name = "registration/registration_form.html" + success_url = reverse_lazy("accounts:register-complete") + + +class RegistrationCompleteView(TemplateView): + template_name = "registration/registration_complete.html" + + +class RegistrationClosedView(TemplateView): + template_name = "registration/registration_closed.html" + + +# Redirects or renders failed activation template +class ActivationView(registration_views.ActivationView): + template_name = "registration/activation_failure.html" + + def get_success_url(self, user): + return ("accounts:activate-complete", (), {}) + + +class ActivationCompleteView(TemplateView): + template_name = "registration/activation_complete.html" + + +# Renders activation form resend or resend_activation_complete +class ActivationResendView(registration_views.ResendActivationView): + template_name = "registration/activation_resend_form.html" + + def render_form_submitted_template(self, form): + """ + Renders resend activation complete template with the submitted email. + + """ + email = form.cleaned_data["email"] + context = {"email": email} + + return render( + self.request, "registration/activation_resend_complete.html", context + ) diff --git a/src/newsreader/accounts/views/settings.py b/src/newsreader/accounts/views/settings.py new file mode 100644 index 0000000..1603252 --- /dev/null +++ b/src/newsreader/accounts/views/settings.py @@ -0,0 +1,26 @@ +from django.urls import reverse_lazy +from django.views.generic.edit import FormView, ModelFormMixin + +from newsreader.accounts.forms import UserSettingsForm +from newsreader.accounts.models import User +from newsreader.news.collection.reddit import ( + get_reddit_access_token, + get_reddit_authorization_url, +) + + +class SettingsView(ModelFormMixin, FormView): + template_name = "accounts/views/settings.html" + success_url = reverse_lazy("accounts:settings") + form_class = UserSettingsForm + model = User + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + return super().get(request, *args, **kwargs) + + def get_object(self, **kwargs): + return self.request.user + + def get_form_kwargs(self): + return {**super().get_form_kwargs(), "instance": self.request.user} diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 7b8c0b6..d41f352 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -201,7 +201,16 @@ VERSION = get_current_version() # Reddit integration REDDIT_CLIENT_ID = "CLIENT_ID" REDDIT_CLIENT_SECRET = "CLIENT_SECRET" -REDDIT_REDIRECT_URL = "http://127.0.0.1:8000/accounts/settings/reddit/callback/" +REDDIT_REDIRECT_URL = ( + "http://127.0.0.1:8000/accounts/settings/integrations/reddit/callback/" +) + +# Twitter integration +TWITTER_CONSUMER_ID = "CONSUMER_ID" +TWITTER_CONSUMER_SECRET = "CONSUMER_SECRET" +TWITTER_REDIRECT_URL = ( + "http://127.0.0.1:8000/accounts/settings/integrations/twitter/callback/" +) # Third party settings AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler" diff --git a/src/newsreader/conf/production.py b/src/newsreader/conf/production.py index bfe9818..f481885 100644 --- a/src/newsreader/conf/production.py +++ b/src/newsreader/conf/production.py @@ -46,9 +46,14 @@ TEMPLATES = [ ] # Reddit integration -REDDIT_CLIENT_ID = os.environ["REDDIT_CLIENT_ID"] -REDDIT_CLIENT_SECRET = os.environ["REDDIT_CLIENT_SECRET"] -REDDIT_REDIRECT_URL = os.environ["REDDIT_CALLBACK_URL"] +REDDIT_CLIENT_ID = os.environ.get("REDDIT_CLIENT_ID", "") +REDDIT_CLIENT_SECRET = os.environ.get("REDDIT_CLIENT_SECRET", "") +REDDIT_REDIRECT_URL = os.environ.get("REDDIT_CALLBACK_URL", "") + +# Twitter integration +TWITTER_CONSUMER_ID = os.environ.get("TWITTER_CONSUMER_ID", "") +TWITTER_CONSUMER_SECRET = os.environ.get("TWITTER_CONSUMER_SECRET", "") +TWITTER_REDIRECT_URL = os.environ.get("TWITTER_REDIRECT_URL", "") # Third party settings AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler" diff --git a/src/newsreader/fixtures/default-fixture.json b/src/newsreader/fixtures/default-fixture.json index 10d6416..1794742 100644 --- a/src/newsreader/fixtures/default-fixture.json +++ b/src/newsreader/fixtures/default-fixture.json @@ -1,4023 +1,4023 @@ -[ -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "admin", - "model": "logentry" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "auth", - "model": "permission" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "auth", - "model": "group" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "contenttypes", - "model": "contenttype" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "sessions", - "model": "session" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "django_celery_beat", - "model": "crontabschedule" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "django_celery_beat", - "model": "intervalschedule" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "django_celery_beat", - "model": "periodictask" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "django_celery_beat", - "model": "periodictasks" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "django_celery_beat", - "model": "solarschedule" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "django_celery_beat", - "model": "clockedschedule" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "registration", - "model": "registrationprofile" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "registration", - "model": "supervisedregistrationprofile" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "axes", - "model": "accessattempt" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "axes", - "model": "accesslog" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "accounts", - "model": "user" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "core", - "model": "post" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "core", - "model": "category" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "collection", - "model": "collectionrule" - } -}, -{ - "model": "sessions.session", - "pk": "3sumq22krk8tsvexcs4b8czu82yhvuer", - "fields": { - "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", - "expire_date": "2020-05-16T18:29:04.049Z" - } -}, -{ - "model": "sessions.session", - "pk": "8ix6bdwf2ywk0eir1hb062dhfh9xit85", - "fields": { - "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", - "expire_date": "2020-07-21T19:36:54.530Z" - } -}, -{ - "model": "sessions.session", - "pk": "d4wophwpjm8z96doe8iddvhdv9yfafyx", - "fields": { - "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", - "expire_date": "2020-06-07T19:45:49.727Z" - } -}, -{ - "model": "sessions.session", - "pk": "g23ziz66li5zx8nd8cewb3vevdxhjkm0", - "fields": { - "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", - "expire_date": "2020-06-30T06:55:50.747Z" - } -}, -{ - "model": "sessions.session", - "pk": "jwn66dptmdkm6hom2ns3j288aaxqtyjd", - "fields": { - "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", - "expire_date": "2020-06-07T18:38:19.116Z" - } -}, -{ - "model": "sessions.session", - "pk": "wjz6kwg5e5ciemre0l0wwyrcwcj2gyg6", - "fields": { - "session_data": "MWU5ODBjY2QyOTFhMmRiY2QyYjQwZjQ3MmMwYmExYjBlYTkxNTcwODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiI0YWZkYTkxNzU5ZDBhZDZmMjg1ZTQyOGY0OTUxN2M5MTJhMmM5NWIyIn0=", - "expire_date": "2020-08-09T09:52:04.705Z" - } -}, -{ - "model": "django_celery_beat.intervalschedule", - "pk": 1, - "fields": { - "every": 5, - "period": "minutes" - } -}, -{ - "model": "django_celery_beat.intervalschedule", - "pk": 2, - "fields": { - "every": 15, - "period": "minutes" - } -}, -{ - "model": "django_celery_beat.intervalschedule", - "pk": 3, - "fields": { - "every": 30, - "period": "minutes" - } -}, -{ - "model": "django_celery_beat.intervalschedule", - "pk": 4, - "fields": { - "every": 1, - "period": "hours" - } -}, -{ - "model": "django_celery_beat.intervalschedule", - "pk": 5, - "fields": { - "every": 4, - "period": "hours" - } -}, -{ - "model": "django_celery_beat.crontabschedule", - "pk": 1, - "fields": { - "minute": "0", - "hour": "4", - "day_of_week": "*", - "day_of_month": "*", - "month_of_year": "*", - "timezone": "UTC" - } -}, -{ - "model": "django_celery_beat.periodictasks", - "pk": 1, - "fields": { - "last_update": "2020-07-26T09:47:48.298Z" - } -}, -{ - "model": "django_celery_beat.periodictask", - "pk": 1, - "fields": { - "name": "celery.backend_cleanup", - "task": "celery.backend_cleanup", - "interval": null, - "crontab": 1, - "solar": null, - "clocked": null, - "args": "[]", - "kwargs": "{}", - "queue": null, - "exchange": null, - "routing_key": null, - "headers": "{}", - "priority": null, - "expires": null, - "expire_seconds": 43200, - "one_off": false, - "start_time": null, - "enabled": true, - "last_run_at": "2020-07-26T09:47:48.322Z", - "total_run_count": 17, - "date_changed": "2020-07-26T09:47:50.362Z", - "description": "" - } -}, -{ - "model": "django_celery_beat.periodictask", - "pk": 10, - "fields": { - "name": "sonny@bakker.nl-collection-task", - "task": "FeedTask", - "interval": 5, - "crontab": null, - "solar": null, - "clocked": null, - "args": "[1]", - "kwargs": "{}", - "queue": null, - "exchange": null, - "routing_key": null, - "headers": "{}", - "priority": null, - "expires": null, - "expire_seconds": null, - "one_off": false, - "start_time": null, - "enabled": false, - "last_run_at": "2020-07-14T11:45:26.209Z", - "total_run_count": 307, - "date_changed": "2020-07-14T11:45:41.282Z", - "description": "" - } -}, -{ - "model": "django_celery_beat.periodictask", - "pk": 11, - "fields": { - "name": "Reddit collection task", - "task": "RedditTask", - "interval": 5, - "crontab": null, - "solar": null, - "clocked": null, - "args": "[]", - "kwargs": "{}", - "queue": null, - "exchange": null, - "routing_key": null, - "headers": "{}", - "priority": null, - "expires": null, - "expire_seconds": null, - "one_off": false, - "start_time": null, - "enabled": false, - "last_run_at": null, - "total_run_count": 4, - "date_changed": "2020-07-14T11:45:41.316Z", - "description": "" - } -}, -{ - "model": "core.post", - "pk": 3061, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-21T20:14:50.423Z", - "title": "Star Citizen: Question and Answer Thread", - "body": "

      Welcome to the Star Citizen question and answer thread. Feel free to ask any questions you have related to SC here!

      \n\n\n\n

      Useful Links and Resources:

      \n\n

      Star Citizen Wiki - The biggest and best wiki resource dedicated to Star Citizen

      \n\n

      Star Citizen FAQ - Chances the answer you need is here.

      \n\n

      Discord Help Channel - Often times community members will be here to help you with issues.

      \n\n

      Referral Code Randomizer - Use this when creating a new account to get 5000 extra UEC.

      \n\n

      Download Star Citizen - Get the latest version of Star Citizen here

      \n\n

      Current Game Features - Click here to see what you can currently do in Star Citizen.

      \n\n

      Development Roadmap - The current development status of up and coming Star Citizen features.

      \n\n

      Pledge FAQ - Official FAQ regarding spending money on the game.

      \n
      ", - "author": "UEE_Central_Computer", - "publication_date": "2020-07-20T14:00:10Z", - "url": "https://www.reddit.com/r/starcitizen/comments/huk04t/star_citizen_question_and_answer_thread/", - "read": false, - "rule": 82, - "remote_identifier": "huk04t" - } -}, -{ - "model": "core.post", - "pk": 3062, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-20T19:33:37.019Z", - "title": "Peace and Quiet", - "body": "
      \"Peace
      ", - "author": "SourMemeNZ", - "publication_date": "2020-07-20T14:09:49Z", - "url": "https://www.reddit.com/r/starcitizen/comments/huk4ib/peace_and_quiet/", - "read": true, - "rule": 82, - "remote_identifier": "huk4ib" - } -}, -{ - "model": "core.post", - "pk": 3063, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-21T20:14:50.463Z", - "title": "Y'all are probably sick of em by now but here's my LEGO Mercury Star Runner (MSR).", - "body": "
      \"Y'all
      ", - "author": "osamadabinman", - "publication_date": "2020-07-20T19:53:23Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hupzqa/yall_are_probably_sick_of_em_by_now_but_heres_my/", - "read": true, - "rule": 82, - "remote_identifier": "hupzqa" - } -}, -{ - "model": "core.post", - "pk": 3064, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-21T20:17:12.253Z", - "title": "Damned Space Invaders and their pixel weapons!", - "body": "
      \"Damned
      ", - "author": "Akaradrin", - "publication_date": "2020-07-20T14:26:18Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hukckf/damned_space_invaders_and_their_pixel_weapons/", - "read": true, - "rule": 82, - "remote_identifier": "hukckf" - } -}, -{ - "model": "core.post", - "pk": 3065, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-20T19:32:35.578Z", - "title": "The sky is no longer the limit", - "body": "
      \"The
      ", - "author": "CyberTill", - "publication_date": "2020-07-20T14:11:31Z", - "url": "https://www.reddit.com/r/starcitizen/comments/huk5b8/the_sky_is_no_longer_the_limit/", - "read": false, - "rule": 82, - "remote_identifier": "huk5b8" - } -}, -{ - "model": "core.post", - "pk": 3066, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-21T20:17:23.282Z", - "title": "Terrapin Hover Mode Gameplay [Full Video in Comments]", - "body": "
      ", - "author": "Didactic_Tomato", - "publication_date": "2020-07-20T11:01:13Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hui1gv/terrapin_hover_mode_gameplay_full_video_in/", - "read": true, - "rule": 82, - "remote_identifier": "hui1gv" - } -}, -{ - "model": "core.post", - "pk": 3067, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-21T20:17:44.250Z", - "title": "honestly", - "body": "
      \"honestly\"
      ", - "author": "Beatlead", - "publication_date": "2020-07-20T18:24:07Z", - "url": "https://www.reddit.com/r/starcitizen/comments/huo96t/honestly/", - "read": true, - "rule": 82, - "remote_identifier": "huo96t" - } -}, -{ - "model": "core.post", - "pk": 3068, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-20T19:32:35.584Z", - "title": "As a paranoiac and tired of checking if door was closed, saved to f4 thoses \"security cam\" positions, could be usefull for larger ships :)", - "body": "", - "author": "icwiener__", - "publication_date": "2020-07-20T13:03:33Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hujchz/as_a_paranoiac_and_tired_of_checking_if_door_was/", - "read": false, - "rule": 82, - "remote_identifier": "hujchz" - } -}, -{ - "model": "core.post", - "pk": 3069, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-20T19:33:59.158Z", - "title": "Station Manager: \"You're too fat, we won't let you in, go and fall on Lorville. Thank you for your call!\" Me: \"okay :'(\"", - "body": "
      \"Station
      ", - "author": "Shaman_N_One", - "publication_date": "2020-07-20T11:33:38Z", - "url": "https://www.reddit.com/r/starcitizen/comments/huidlu/station_manager_youre_too_fat_we_wont_let_you_in/", - "read": true, - "rule": 82, - "remote_identifier": "huidlu" - } -}, -{ - "model": "core.post", - "pk": 3070, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-20T19:32:35.588Z", - "title": "[PTU Bug Hunt Request] Packet Loss", - "body": "", - "author": "Rainwalker007", - "publication_date": "2020-07-20T18:38:03Z", - "url": "https://www.reddit.com/r/starcitizen/comments/huoicq/ptu_bug_hunt_request_packet_loss/", - "read": false, - "rule": 82, - "remote_identifier": "huoicq" - } -}, -{ - "model": "core.post", - "pk": 3071, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-21T20:17:52.092Z", - "title": "Anyone able to explain these \"trail frames\"?", - "body": "
      \"Anyone
      ", - "author": "Abnormal_Sloth", - "publication_date": "2020-07-20T17:11:32Z", - "url": "https://www.reddit.com/r/starcitizen/comments/humyeq/anyone_able_to_explain_these_trail_frames/", - "read": true, - "rule": 82, - "remote_identifier": "humyeq" - } -}, -{ - "model": "core.post", - "pk": 3072, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-20T19:32:35.593Z", - "title": "#BringBackBugSmasher - A long forgotten legendary video content", - "body": "", - "author": "MasterBoring", - "publication_date": "2020-07-20T18:05:54Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hunx77/bringbackbugsmasher_a_long_forgotten_legendary/", - "read": false, - "rule": 82, - "remote_identifier": "hunx77" - } -}, -{ - "model": "core.post", - "pk": 3073, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-20T19:33:22.601Z", - "title": "Oracle Helmet [in-game screenshot; downsampled to 4k]", - "body": "
      \"Oracle
      ", - "author": "mr-hasgaha", - "publication_date": "2020-07-20T17:39:34Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hung0b/oracle_helmet_ingame_screenshot_downsampled_to_4k/", - "read": true, - "rule": 82, - "remote_identifier": "hung0b" - } -}, -{ - "model": "core.post", - "pk": 3074, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-20T19:34:42.578Z", - "title": "Testing 3.10 - Gladius in decoupled mode", - "body": "
      ", - "author": "DarkConstant", - "publication_date": "2020-07-19T21:26:52Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hu6f1h/testing_310_gladius_in_decoupled_mode/", - "read": true, - "rule": 82, - "remote_identifier": "hu6f1h" - } -}, -{ - "model": "core.post", - "pk": 3075, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-20T19:34:29.424Z", - "title": "Day 3, I can't stop taking pictures with my Carrack. Send help", - "body": "
      \"Day
      ", - "author": "CyberTill", - "publication_date": "2020-07-20T01:58:15Z", - "url": "https://www.reddit.com/r/starcitizen/comments/huazyy/day_3_i_cant_stop_taking_pictures_with_my_carrack/", - "read": true, - "rule": 82, - "remote_identifier": "huazyy" - } -}, -{ - "model": "core.post", - "pk": 3076, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-20T19:32:35.602Z", - "title": "I used to enjoy flying between the buildings of new babbage, I mean before the NFZ \"improvement\"", - "body": "
      \"I
      ", - "author": "shoeii", - "publication_date": "2020-07-20T16:40:26Z", - "url": "https://www.reddit.com/r/starcitizen/comments/humet2/i_used_to_enjoy_flying_between_the_buildings_of/", - "read": false, - "rule": 82, - "remote_identifier": "humet2" - } -}, -{ - "model": "core.post", - "pk": 3077, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-21T20:18:04.237Z", - "title": "Thank you CIG for updated heightmaps and render distances", - "body": "
      \"Thank
      ", - "author": "u7f76", - "publication_date": "2020-07-19T23:38:22Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hu8pwf/thank_you_cig_for_updated_heightmaps_and_render/", - "read": true, - "rule": 82, - "remote_identifier": "hu8pwf" - } -}, -{ - "model": "core.post", - "pk": 3078, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-20T19:32:35.607Z", - "title": "This Week in Star Citizen | July 20th 2020", - "body": "", - "author": "ivtiprogamer", - "publication_date": "2020-07-20T19:50:29Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hupxnt/this_week_in_star_citizen_july_20th_2020/", - "read": false, - "rule": 82, - "remote_identifier": "hupxnt" - } -}, -{ - "model": "core.post", - "pk": 3079, - "fields": { - "created": "2020-07-20T19:32:35.563Z", - "modified": "2020-07-20T19:34:36.068Z", - "title": "Bravo CIG lighting team! Noticeable improvements to all around environment lighting in 3.10", - "body": "
      \"Bravo
      ", - "author": "u7f76", - "publication_date": "2020-07-20T00:02:23Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hu94o0/bravo_cig_lighting_team_noticeable_improvements/", - "read": true, - "rule": 82, - "remote_identifier": "hu94o0" - } -}, -{ - "model": "core.post", - "pk": 3080, - "fields": { - "created": "2020-07-20T19:32:35.563Z", - "modified": "2020-07-20T19:32:35.613Z", - "title": "Thick", - "body": "
      \"Thick\"
      ", - "author": "burgerbagel", - "publication_date": "2020-07-20T16:24:38Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hum50f/thick/", - "read": false, - "rule": 82, - "remote_identifier": "hum50f" - } -}, -{ - "model": "core.post", - "pk": 3081, - "fields": { - "created": "2020-07-20T19:32:35.563Z", - "modified": "2020-07-20T19:34:19.763Z", - "title": "Soon\u2122", - "body": "
      \"Soon\u2122\"
      ", - "author": "Mistralette", - "publication_date": "2020-07-20T05:54:09Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hueg01/soon/", - "read": true, - "rule": 82, - "remote_identifier": "hueg01" - } -}, -{ - "model": "core.post", - "pk": 3082, - "fields": { - "created": "2020-07-20T19:32:35.563Z", - "modified": "2020-07-20T19:32:35.618Z", - "title": "On the prowl", - "body": "
      \"On
      ", - "author": "SaraCaterina", - "publication_date": "2020-07-20T16:37:03Z", - "url": "https://www.reddit.com/r/starcitizen/comments/humcmb/on_the_prowl/", - "read": false, - "rule": 82, - "remote_identifier": "humcmb" - } -}, -{ - "model": "core.post", - "pk": 3083, - "fields": { - "created": "2020-07-20T19:32:35.563Z", - "modified": "2020-07-20T19:34:07.272Z", - "title": "The Hills Have Eyes", - "body": "
      \"The
      ", - "author": "FallenLordik", - "publication_date": "2020-07-20T11:19:19Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hui8ao/the_hills_have_eyes/", - "read": true, - "rule": 82, - "remote_identifier": "hui8ao" - } -}, -{ - "model": "core.post", - "pk": 3084, - "fields": { - "created": "2020-07-20T19:32:35.563Z", - "modified": "2020-07-20T19:32:35.623Z", - "title": "Worried about longer loading screens? Hit ~ and do r_displayinfo 3", - "body": "
      \"Worried
      ", - "author": "kristokn", - "publication_date": "2020-07-20T10:09:53Z", - "url": "https://www.reddit.com/r/starcitizen/comments/huhif1/worried_about_longer_loading_screens_hit_and_do_r/", - "read": false, - "rule": 82, - "remote_identifier": "huhif1" - } -}, -{ - "model": "core.post", - "pk": 3085, - "fields": { - "created": "2020-07-20T19:32:35.563Z", - "modified": "2020-07-20T19:32:35.625Z", - "title": "My contribution to the wallpaper contest... click for the full effect (3440x1440)", - "body": "
      \"My
      ", - "author": "Dougie_Juice", - "publication_date": "2020-07-20T20:02:31Z", - "url": "https://www.reddit.com/r/starcitizen/comments/huq655/my_contribution_to_the_wallpaper_contest_click/", - "read": false, - "rule": 82, - "remote_identifier": "huq655" - } -}, -{ - "model": "core.post", - "pk": 3086, - "fields": { - "created": "2020-07-20T19:32:35.563Z", - "modified": "2020-07-20T19:32:35.627Z", - "title": "Star Citizen: The Onion (Parody Project)", - "body": "", - "author": "BroadOne", - "publication_date": "2020-07-20T19:19:20Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hupbkj/star_citizen_the_onion_parody_project/", - "read": false, - "rule": 82, - "remote_identifier": "hupbkj" - } -}, -{ - "model": "core.post", - "pk": 3087, - "fields": { - "created": "2020-07-20T19:32:35.635Z", - "modified": "2020-07-20T19:32:35.637Z", - "title": "perfect day to sunbathe", - "body": "
      ", - "author": "Pedrica1", - "publication_date": "2020-07-20T18:08:17Z", - "url": "https://www.reddit.com/r/aww/comments/hunysb/perfect_day_to_sunbathe/", - "read": false, - "rule": 81, - "remote_identifier": "hunysb" - } -}, -{ - "model": "core.post", - "pk": 3088, - "fields": { - "created": "2020-07-20T19:32:35.635Z", - "modified": "2020-07-20T19:32:35.639Z", - "title": "My dogs face when he sees I'm home", - "body": "
      ", - "author": "NewReddit_WhoDis", - "publication_date": "2020-07-20T16:45:21Z", - "url": "https://www.reddit.com/r/aww/comments/humhxa/my_dogs_face_when_he_sees_im_home/", - "read": false, - "rule": 81, - "remote_identifier": "humhxa" - } -}, -{ - "model": "core.post", - "pk": 3089, - "fields": { - "created": "2020-07-20T19:32:35.635Z", - "modified": "2020-07-20T19:32:35.641Z", - "title": "Cow loves the scritch machine", - "body": "
      ", - "author": "Der_Ist", - "publication_date": "2020-07-20T17:36:16Z", - "url": "https://www.reddit.com/r/aww/comments/hundvo/cow_loves_the_scritch_machine/", - "read": false, - "rule": 81, - "remote_identifier": "hundvo" - } -}, -{ - "model": "core.post", - "pk": 3090, - "fields": { - "created": "2020-07-20T19:32:35.635Z", - "modified": "2020-07-20T19:32:35.643Z", - "title": "Can I sit next to you ?", - "body": "
      ", - "author": "wheezy098", - "publication_date": "2020-07-20T17:55:10Z", - "url": "https://www.reddit.com/r/aww/comments/hunq5h/can_i_sit_next_to_you/", - "read": false, - "rule": 81, - "remote_identifier": "hunq5h" - } -}, -{ - "model": "core.post", - "pk": 3091, - "fields": { - "created": "2020-07-20T19:32:35.635Z", - "modified": "2020-07-20T19:32:35.645Z", - "title": "IS THAT A CUSTOMER? flop flop flop flop .... \" Can I uhh... help you sir?\"", - "body": "
      ", - "author": "MBMV", - "publication_date": "2020-07-20T12:50:40Z", - "url": "https://www.reddit.com/r/aww/comments/huj7g3/is_that_a_customer_flop_flop_flop_flop_can_i_uhh/", - "read": false, - "rule": 81, - "remote_identifier": "huj7g3" - } -}, -{ - "model": "core.post", - "pk": 3092, - "fields": { - "created": "2020-07-20T19:32:35.635Z", - "modified": "2020-07-20T19:32:35.647Z", - "title": "Good Boy turned Disney Princess", - "body": "
      ", - "author": "Sauwercraud", - "publication_date": "2020-07-20T18:40:05Z", - "url": "https://www.reddit.com/r/aww/comments/huojq0/good_boy_turned_disney_princess/", - "read": false, - "rule": 81, - "remote_identifier": "huojq0" - } -}, -{ - "model": "core.post", - "pk": 3093, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.649Z", - "title": "Kitty loop", - "body": "
      ", - "author": "Dlatrex", - "publication_date": "2020-07-20T12:54:02Z", - "url": "https://www.reddit.com/r/aww/comments/huj8s6/kitty_loop/", - "read": false, - "rule": 81, - "remote_identifier": "huj8s6" - } -}, -{ - "model": "core.post", - "pk": 3094, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.652Z", - "title": "if i fits i sits", - "body": "
      ", - "author": "jasontaken", - "publication_date": "2020-07-20T16:38:32Z", - "url": "https://www.reddit.com/r/aww/comments/humdlf/if_i_fits_i_sits/", - "read": false, - "rule": 81, - "remote_identifier": "humdlf" - } -}, -{ - "model": "core.post", - "pk": 3095, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.654Z", - "title": "Isn\u2019t she Adorable !", - "body": "
      \"Isn\u2019t
      ", - "author": "MunchyMac", - "publication_date": "2020-07-20T16:18:05Z", - "url": "https://www.reddit.com/r/aww/comments/hum133/isnt_she_adorable/", - "read": false, - "rule": 81, - "remote_identifier": "hum133" - } -}, -{ - "model": "core.post", - "pk": 3096, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.655Z", - "title": "Thank you mama (\u2283\uff61\u2022\u0301\u203f\u2022\u0300\uff61)\u2283", - "body": "
      ", - "author": "AnoushkaSingh", - "publication_date": "2020-07-20T13:35:51Z", - "url": "https://www.reddit.com/r/aww/comments/hujpxy/thank_you_mama/", - "read": false, - "rule": 81, - "remote_identifier": "hujpxy" - } -}, -{ - "model": "core.post", - "pk": 3097, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.657Z", - "title": "I WANT TO HUG HIM SO BAD!!!", - "body": "
      ", - "author": "BATMAN_5777", - "publication_date": "2020-07-20T18:25:20Z", - "url": "https://www.reddit.com/r/aww/comments/huo9z4/i_want_to_hug_him_so_bad/", - "read": false, - "rule": 81, - "remote_identifier": "huo9z4" - } -}, -{ - "model": "core.post", - "pk": 3098, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.659Z", - "title": "Before and after being called a good boy", - "body": "
      \"Before
      ", - "author": "vladgrinch", - "publication_date": "2020-07-20T10:48:40Z", - "url": "https://www.reddit.com/r/aww/comments/huhwu9/before_and_after_being_called_a_good_boy/", - "read": false, - "rule": 81, - "remote_identifier": "huhwu9" - } -}, -{ - "model": "core.post", - "pk": 3099, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.662Z", - "title": "My fianc\u00e9 has wanted a dog his whole life. This is his college graduation present. Welcome home Maple!", - "body": "
      \"My
      ", - "author": "AlexisaurusRex", - "publication_date": "2020-07-20T17:57:25Z", - "url": "https://www.reddit.com/r/aww/comments/hunrie/my_fianc\u00e9_has_wanted_a_dog_his_whole_life_this_is/", - "read": false, - "rule": 81, - "remote_identifier": "hunrie" - } -}, -{ - "model": "core.post", - "pk": 3100, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.664Z", - "title": "Cute burro.", - "body": "
      \"Cute
      ", - "author": "Craftmine101", - "publication_date": "2020-07-20T13:45:32Z", - "url": "https://www.reddit.com/r/aww/comments/huju40/cute_burro/", - "read": false, - "rule": 81, - "remote_identifier": "huju40" - } -}, -{ - "model": "core.post", - "pk": 3101, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.666Z", - "title": "I've never seen anyone dance better than that turtle.", - "body": "
      ", - "author": "Ashley1023", - "publication_date": "2020-07-20T18:07:30Z", - "url": "https://www.reddit.com/r/aww/comments/hunya8/ive_never_seen_anyone_dance_better_than_that/", - "read": false, - "rule": 81, - "remote_identifier": "hunya8" - } -}, -{ - "model": "core.post", - "pk": 3102, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.669Z", - "title": "Someone\u2019s going to be quite surprised when he realizes all this new stuff isn\u2019t for him!", - "body": "
      \"Someone\u2019s
      ", - "author": "molly590", - "publication_date": "2020-07-20T15:46:21Z", - "url": "https://www.reddit.com/r/aww/comments/hulikg/someones_going_to_be_quite_surprised_when_he/", - "read": false, - "rule": 81, - "remote_identifier": "hulikg" - } -}, -{ - "model": "core.post", - "pk": 3103, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.671Z", - "title": "my aunt asked me to paint her puppy and I think it turned out so cute!!!", - "body": "
      \"my
      ", - "author": "PineappleLightt", - "publication_date": "2020-07-20T16:39:37Z", - "url": "https://www.reddit.com/r/aww/comments/humea0/my_aunt_asked_me_to_paint_her_puppy_and_i_think/", - "read": false, - "rule": 81, - "remote_identifier": "humea0" - } -}, -{ - "model": "core.post", - "pk": 3104, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.673Z", - "title": "Master Assassin", - "body": "
      \"Master
      ", - "author": "LauWalker", - "publication_date": "2020-07-20T18:47:52Z", - "url": "https://www.reddit.com/r/aww/comments/huop8a/master_assassin/", - "read": false, - "rule": 81, - "remote_identifier": "huop8a" - } -}, -{ - "model": "core.post", - "pk": 3105, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.675Z", - "title": "Every time this tank cleaner cleans out the aquarium, this fish swims over to him looking for pets", - "body": "", - "author": "unnaturalorder", - "publication_date": "2020-07-20T05:29:30Z", - "url": "https://www.reddit.com/r/aww/comments/hue3r0/every_time_this_tank_cleaner_cleans_out_the/", - "read": false, - "rule": 81, - "remote_identifier": "hue3r0" - } -}, -{ - "model": "core.post", - "pk": 3106, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.678Z", - "title": "My girlfriend sent me this while I was at work. And here I was thinking the perfect picture of our dog didn't exist", - "body": "", - "author": "Khuma-zi_Eldrama", - "publication_date": "2020-07-20T19:22:48Z", - "url": "https://www.reddit.com/r/aww/comments/hupdz8/my_girlfriend_sent_me_this_while_i_was_at_work/", - "read": false, - "rule": 81, - "remote_identifier": "hupdz8" - } -}, -{ - "model": "core.post", - "pk": 3107, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.680Z", - "title": "My first ever post, everyone meet my new baby girl Kiora! I\u2019m so in love with her\ud83e\udd7a\ud83d\udcab", - "body": "
      \"My
      ", - "author": "Dumpling2463", - "publication_date": "2020-07-20T05:34:29Z", - "url": "https://www.reddit.com/r/aww/comments/hue6dx/my_first_ever_post_everyone_meet_my_new_baby_girl/", - "read": false, - "rule": 81, - "remote_identifier": "hue6dx" - } -}, -{ - "model": "core.post", - "pk": 3108, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.682Z", - "title": "Dog splashing in water", - "body": "", - "author": "TheRikari", - "publication_date": "2020-07-20T15:44:02Z", - "url": "https://www.reddit.com/r/aww/comments/hulh8k/dog_splashing_in_water/", - "read": false, - "rule": 81, - "remote_identifier": "hulh8k" - } -}, -{ - "model": "core.post", - "pk": 3109, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.685Z", - "title": "They say taking breaks is the key to productivity!", - "body": "
      ", - "author": "Thereaper29", - "publication_date": "2020-07-20T05:43:40Z", - "url": "https://www.reddit.com/r/aww/comments/hueawt/they_say_taking_breaks_is_the_key_to_productivity/", - "read": false, - "rule": 81, - "remote_identifier": "hueawt" - } -}, -{ - "model": "core.post", - "pk": 3110, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.687Z", - "title": "I went away for 3 weeks, and now my cat is in love with my husband", - "body": "
      \"I
      ", - "author": "sillykittyish", - "publication_date": "2020-07-20T03:29:11Z", - "url": "https://www.reddit.com/r/aww/comments/hucd7u/i_went_away_for_3_weeks_and_now_my_cat_is_in_love/", - "read": false, - "rule": 81, - "remote_identifier": "hucd7u" - } -}, -{ - "model": "core.post", - "pk": 3111, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.689Z", - "title": "Can you feel the love", - "body": "
      ", - "author": "kettySewrdPic", - "publication_date": "2020-07-20T09:13:32Z", - "url": "https://www.reddit.com/r/aww/comments/hugx1k/can_you_feel_the_love/", - "read": false, - "rule": 81, - "remote_identifier": "hugx1k" - } -}, -{ - "model": "core.post", - "pk": 3112, - "fields": { - "created": "2020-07-20T19:32:35.835Z", - "modified": "2020-07-21T20:14:50.522Z", - "title": "Linux Experiences/Rants or Education/Certifications thread - July 20, 2020", - "body": "

      Welcome to r/linux rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.

      \n\n

      Let us know what's annoying you, whats making you happy, or something that you want to get out to r/linux but didn't make the cut into a full post of it's own.

      \n\n

      For those looking for certifications please use this megathread to ask about how to get certified whether it's for the business world or for your own satisfaction. Be sure to check out r/linuxadmin for more discussion in the SysAdmin world!

      \n\n

      Please keep questions in r/linuxquestions, r/linux4noobs, or the Wednesday automod thread.

      \n
      ", - "author": "AutoModerator", - "publication_date": "2020-07-20T06:12:00Z", - "url": "https://www.reddit.com/r/linux/comments/hueoo0/linux_experiencesrants_or_educationcertifications/", - "read": false, - "rule": 80, - "remote_identifier": "hueoo0" - } -}, -{ - "model": "core.post", - "pk": 3113, - "fields": { - "created": "2020-07-20T19:32:35.836Z", - "modified": "2020-07-21T20:19:49.339Z", - "title": "Unix Family Tree", - "body": "
      \"Unix
      ", - "author": "bauripalash", - "publication_date": "2020-07-20T10:32:15Z", - "url": "https://www.reddit.com/r/linux/comments/huhqrh/unix_family_tree/", - "read": true, - "rule": 80, - "remote_identifier": "huhqrh" - } -}, -{ - "model": "core.post", - "pk": 3114, - "fields": { - "created": "2020-07-20T19:32:35.836Z", - "modified": "2020-07-21T20:14:50.554Z", - "title": "NVIDIA open sourced part of NVAPI SDK to aid 'Windows emulation environments'", - "body": "", - "author": "ignapk", - "publication_date": "2020-07-20T13:17:19Z", - "url": "https://www.reddit.com/r/linux/comments/huji8c/nvidia_open_sourced_part_of_nvapi_sdk_to_aid/", - "read": false, - "rule": 80, - "remote_identifier": "huji8c" - } -}, -{ - "model": "core.post", - "pk": 3115, - "fields": { - "created": "2020-07-20T19:32:35.836Z", - "modified": "2020-07-21T20:14:50.551Z", - "title": "Jellyfin 10.6 released", - "body": "", - "author": "resoluti0n_", - "publication_date": "2020-07-20T16:40:05Z", - "url": "https://www.reddit.com/r/linux/comments/humekr/jellyfin_106_released/", - "read": false, - "rule": 80, - "remote_identifier": "humekr" - } -}, -{ - "model": "core.post", - "pk": 3116, - "fields": { - "created": "2020-07-20T19:32:35.836Z", - "modified": "2020-07-21T20:14:50.583Z", - "title": "[German] Article in major german newspaper about trying Linux and WSL. Literal: \"Why it's beneficial to try Linux now\"", - "body": "", - "author": "noname7890", - "publication_date": "2020-07-19T15:19:27Z", - "url": "https://www.reddit.com/r/linux/comments/hu0d5v/german_article_in_major_german_newspaper_about/", - "read": false, - "rule": 80, - "remote_identifier": "hu0d5v" - } -}, -{ - "model": "core.post", - "pk": 3117, - "fields": { - "created": "2020-07-20T19:32:35.837Z", - "modified": "2020-07-21T20:14:50.574Z", - "title": "Brian Kernighan: UNIX, C, AWK, AMPL, and Go Programming | AI Podcast #109 with Lex Fridman", - "body": "", - "author": "tinyatom", - "publication_date": "2020-07-20T08:48:35Z", - "url": "https://www.reddit.com/r/linux/comments/hugn0w/brian_kernighan_unix_c_awk_ampl_and_go/", - "read": false, - "rule": 80, - "remote_identifier": "hugn0w" - } -}, -{ - "model": "core.post", - "pk": 3118, - "fields": { - "created": "2020-07-20T19:32:35.837Z", - "modified": "2020-07-21T20:14:50.578Z", - "title": "Explaining Computers Host Christopher Barnatt Has Switched To Linux", - "body": "", - "author": "sysrpl", - "publication_date": "2020-07-20T13:00:02Z", - "url": "https://www.reddit.com/r/linux/comments/hujb12/explaining_computers_host_christopher_barnatt_has/", - "read": false, - "rule": 80, - "remote_identifier": "hujb12" - } -}, -{ - "model": "core.post", - "pk": 3119, - "fields": { - "created": "2020-07-20T19:32:35.837Z", - "modified": "2020-07-21T20:14:50.529Z", - "title": "Ireland donates contact tracing app to the Linux foundation.", - "body": "", - "author": "mathiasryan", - "publication_date": "2020-07-20T21:31:43Z", - "url": "https://www.reddit.com/r/linux/comments/hury4e/ireland_donates_contact_tracing_app_to_the_linux/", - "read": false, - "rule": 80, - "remote_identifier": "hury4e" - } -}, -{ - "model": "core.post", - "pk": 3120, - "fields": { - "created": "2020-07-20T19:32:35.842Z", - "modified": "2020-07-21T20:14:50.588Z", - "title": "I implemented a simple terminal-based password manager", - "body": "

      I created a simple, secure, and free password manager written in C: SaltPass. I haven't contributed open source code before, but I think this might be useful to a few people. Especially as an alternative to paid solutions such as LastPass and the likes. Any suggestions/edits/code improvements would be greatly appreciated!

      \n
      ", - "author": "zaid-gg", - "publication_date": "2020-07-20T07:43:03Z", - "url": "https://www.reddit.com/r/linux/comments/hufula/i_implemented_a_simple_terminalbased_password/", - "read": false, - "rule": 80, - "remote_identifier": "hufula" - } -}, -{ - "model": "core.post", - "pk": 3121, - "fields": { - "created": "2020-07-20T19:32:35.843Z", - "modified": "2020-07-21T20:14:50.593Z", - "title": "Performance analysis of multi services on container Docker, LXC, and LXD - Bulletin of Electrical Engineering and Informatics, Adinda Riztia Putri, Rendy Munadi, Ridha Muldina Negara Adaptive Network\u2026", - "body": "", - "author": "bmullan", - "publication_date": "2020-07-20T11:35:59Z", - "url": "https://www.reddit.com/r/linux/comments/huieio/performance_analysis_of_multi_services_on/", - "read": false, - "rule": 80, - "remote_identifier": "huieio" - } -}, -{ - "model": "core.post", - "pk": 3122, - "fields": { - "created": "2020-07-20T19:32:35.844Z", - "modified": "2020-07-21T20:14:50.602Z", - "title": "Create an Internal PKI using OpenSSL and NitroKey HSM", - "body": "", - "author": "PixelPaulaus", - "publication_date": "2020-07-20T06:18:41Z", - "url": "https://www.reddit.com/r/linux/comments/huerpn/create_an_internal_pki_using_openssl_and_nitrokey/", - "read": false, - "rule": 80, - "remote_identifier": "huerpn" - } -}, -{ - "model": "core.post", - "pk": 3123, - "fields": { - "created": "2020-07-20T19:32:35.844Z", - "modified": "2020-07-20T19:32:35.883Z", - "title": "vopono - run applications via VPNs with temporary network namespaces", - "body": "", - "author": "nivenkos", - "publication_date": "2020-07-19T20:02:57Z", - "url": "https://www.reddit.com/r/linux/comments/hu4vge/vopono_run_applications_via_vpns_with_temporary/", - "read": false, - "rule": 80, - "remote_identifier": "hu4vge" - } -}, -{ - "model": "core.post", - "pk": 3124, - "fields": { - "created": "2020-07-20T19:32:35.849Z", - "modified": "2020-07-20T19:32:35.886Z", - "title": "Double (triple, quadruple...) internet speed with openvpn tap channel bonding to a linux VPS", - "body": "

      I have been working a couple of days on my latest video about channel bonding - the video is heavily inspired be this article on Serverfault. In essence, I have been searching for a while on how to bond multiple VPN channels together in order to increase internet speed - there does not seem to be a lot of information around - mainly articles on forums and reddit state that it should be possible but a detailed guide is hard to find. I am using two Ubuntu machines in order to build the connection - one local and one VPS. The bash scripts I use in my video in order to achieve tap channel bonding are available on my github repository. I am currently working on a second video in order to walk through and explain the scripts in depth. Enjoy!

      \n\n

      (EDIT) - the question has come up in the discussions below if this is really packet load balancing or rather balancing links only - please see my comment further down - I can confirm that this DOES packet balancing so it does work as described.

      \n
      ", - "author": "onemarcfifty", - "publication_date": "2020-07-19T20:41:40Z", - "url": "https://www.reddit.com/r/linux/comments/hu5l4f/double_triple_quadruple_internet_speed_with/", - "read": false, - "rule": 80, - "remote_identifier": "hu5l4f" - } -}, -{ - "model": "core.post", - "pk": 3125, - "fields": { - "created": "2020-07-20T19:32:35.849Z", - "modified": "2020-07-20T19:32:35.888Z", - "title": "OpenRGB - Open source RGB lighting control that doesn't depend on manufacturer software, supports Linux", - "body": "", - "author": "pr0_c0d3", - "publication_date": "2020-07-18T16:52:48Z", - "url": "https://www.reddit.com/r/linux/comments/hthuli/openrgb_open_source_rgb_lighting_control_that/", - "read": false, - "rule": 80, - "remote_identifier": "hthuli" - } -}, -{ - "model": "core.post", - "pk": 3126, - "fields": { - "created": "2020-07-20T19:32:35.849Z", - "modified": "2020-07-20T19:32:35.890Z", - "title": "Make this any sense? Automatic CPU Speed & Power Optimizer", - "body": "", - "author": "spite77", - "publication_date": "2020-07-20T11:53:35Z", - "url": "https://www.reddit.com/r/linux/comments/huikxz/make_this_any_sense_automatic_cpu_speed_power/", - "read": false, - "rule": 80, - "remote_identifier": "huikxz" - } -}, -{ - "model": "core.post", - "pk": 3127, - "fields": { - "created": "2020-07-20T19:32:35.849Z", - "modified": "2020-07-20T19:32:35.891Z", - "title": "Let\u2019s not be pedantic about \u201cOpen Source\u201d", - "body": "", - "author": "speckz", - "publication_date": "2020-07-20T16:46:43Z", - "url": "https://www.reddit.com/r/linux/comments/humirw/lets_not_be_pedantic_about_open_source/", - "read": false, - "rule": 80, - "remote_identifier": "humirw" - } -}, -{ - "model": "core.post", - "pk": 3128, - "fields": { - "created": "2020-07-20T19:32:35.849Z", - "modified": "2020-07-20T19:32:35.893Z", - "title": "Experiences with running Linux Lite", - "body": "", - "author": "daemonpenguin", - "publication_date": "2020-07-20T02:43:49Z", - "url": "https://www.reddit.com/r/linux/comments/hubonw/experiences_with_running_linux_lite/", - "read": false, - "rule": 80, - "remote_identifier": "hubonw" - } -}, -{ - "model": "core.post", - "pk": 3129, - "fields": { - "created": "2020-07-20T19:32:35.849Z", - "modified": "2020-07-20T19:32:35.895Z", - "title": "Tried gnome on arch, surprised how lean it is (used flameshot so it used about 72mb more) closing at 600 megs) on fedora and pop i had gnome eating up 1.3gigs at boot up.", - "body": "
      \"Tried
      ", - "author": "V1n0dKr1shna", - "publication_date": "2020-07-18T13:54:55Z", - "url": "https://www.reddit.com/r/linux/comments/htfeph/tried_gnome_on_arch_surprised_how_lean_it_is_used/", - "read": false, - "rule": 80, - "remote_identifier": "htfeph" - } -}, -{ - "model": "core.post", - "pk": 3130, - "fields": { - "created": "2020-07-20T19:32:35.849Z", - "modified": "2020-07-20T19:32:35.897Z", - "title": "The Free Software Foundation is holding a Fundraiser, help them reach 200 members", - "body": "", - "author": "Neet-Feet", - "publication_date": "2020-07-18T17:55:30Z", - "url": "https://www.reddit.com/r/linux/comments/htiuyi/the_free_software_foundation_is_holding_a/", - "read": false, - "rule": 80, - "remote_identifier": "htiuyi" - } -}, -{ - "model": "core.post", - "pk": 3131, - "fields": { - "created": "2020-07-20T19:32:35.853Z", - "modified": "2020-07-20T19:32:35.899Z", - "title": "Why is the mindset around Arch so negative?", - "body": "

      I love the Linux community as a whole. You can find some of the most creative and imaginative people within most Linux communities. On a whole, Linux users are some of the most helpful and informative people you can encounter. Truly the type to think outside the box and learn new things. It can be very inspirational.

      \n\n

      If I jumped onto Ubuntu, Fedora, or openSUSE's community I can have a free flowing conversation about Linux, their distribution, and getting help or giving help is so free-flowing and easy. The communities are eager to welcome new people and appreciate folks who contribute.

      \n\n

      Then you have Arch. I love the OS but dislike the mindset. Asking for help is meat with resistance, giving help can also be punishable, and god forbid you try to have a discussion. But it's not just their core community either. For example, I just discovered Endeavour OS which is built around Arch and after 11 post I'm told to come back in 8 hours. Their subReddit here on Reddit, you have to ask to even make 1 post. There of course is also Manjaro Linux and they too have this gatekeeper mindset, the same can be said for ArcoLinux.

      \n\n

      What is it about Arch that makes everyone want to be either a control freak or a gatekeeper?

      \n\n

      I do not see this within the Ubuntu or Fedora or openSUSE communities. As I said, their mindset seems eager and willing to unite and work as a community. Am I the only how has noticed this?

      \n
      ", - "author": "Linux-Is-Best", - "publication_date": "2020-07-18T23:28:12Z", - "url": "https://www.reddit.com/r/linux/comments/htojwk/why_is_the_mindset_around_arch_so_negative/", - "read": false, - "rule": 80, - "remote_identifier": "htojwk" - } -}, -{ - "model": "core.post", - "pk": 3132, - "fields": { - "created": "2020-07-20T19:32:35.853Z", - "modified": "2020-07-20T19:32:35.901Z", - "title": "Using the nstat network statistics command in Linux", - "body": "", - "author": "cronos426", - "publication_date": "2020-07-19T17:55:55Z", - "url": "https://www.reddit.com/r/linux/comments/hu2q6v/using_the_nstat_network_statistics_command_in/", - "read": false, - "rule": 80, - "remote_identifier": "hu2q6v" - } -}, -{ - "model": "core.post", - "pk": 3133, - "fields": { - "created": "2020-07-20T19:32:35.853Z", - "modified": "2020-07-20T19:32:35.903Z", - "title": "Contributing via GitLab Merge Requests", - "body": "", - "author": "ChristophCullmann", - "publication_date": "2020-07-18T20:01:26Z", - "url": "https://www.reddit.com/r/linux/comments/htl05p/contributing_via_gitlab_merge_requests/", - "read": false, - "rule": 80, - "remote_identifier": "htl05p" - } -}, -{ - "model": "core.post", - "pk": 3134, - "fields": { - "created": "2020-07-20T19:32:35.853Z", - "modified": "2020-07-20T19:32:35.905Z", - "title": "OpenMandriva: combines WINE64 and 32 into one package capable of running both binaries, i686 architecture was considered as deprecated. Work is underway on a new Rolling release", - "body": "", - "author": "DamonsLinux", - "publication_date": "2020-07-18T15:02:35Z", - "url": "https://www.reddit.com/r/linux/comments/htg9dj/openmandriva_combines_wine64_and_32_into_one/", - "read": false, - "rule": 80, - "remote_identifier": "htg9dj" - } -}, -{ - "model": "core.post", - "pk": 3135, - "fields": { - "created": "2020-07-20T19:32:35.853Z", - "modified": "2020-07-20T19:32:35.906Z", - "title": "OpenRCT2 Player Survey 2020 - Previous survey shows almost 25% players are linux, please help represent linux in the most recent survey", - "body": "", - "author": "christophski", - "publication_date": "2020-07-18T11:39:06Z", - "url": "https://www.reddit.com/r/linux/comments/htdzuh/openrct2_player_survey_2020_previous_survey_shows/", - "read": false, - "rule": 80, - "remote_identifier": "htdzuh" - } -}, -{ - "model": "core.post", - "pk": 3136, - "fields": { - "created": "2020-07-20T19:32:35.853Z", - "modified": "2020-07-20T19:32:35.908Z", - "title": "This week in KDE: Get New Stuff fixes and more", - "body": "", - "author": "kyentei", - "publication_date": "2020-07-18T10:03:46Z", - "url": "https://www.reddit.com/r/linux/comments/htd1an/this_week_in_kde_get_new_stuff_fixes_and_more/", - "read": false, - "rule": 80, - "remote_identifier": "htd1an" - } -}, -{ - "model": "core.post", - "pk": 3137, - "fields": { - "created": "2020-07-20T19:32:35.857Z", - "modified": "2020-07-20T19:32:35.910Z", - "title": "Blender Runs on Linux Pinephone", - "body": "

      I managed to get the desktop version of Blender on the Pinephone, and it works really well except for a few bugs.

      \n\n

      See my post on r/blender:

      \n\n

      https://www.reddit.com/r/blender/comments/hsxv27/i_installed_blender_on_a_phone/

      \n\n

      and r/PINE64official:

      \n\n

      https://www.reddit.com/r/PINE64official/comments/hsxc33/blender_on_pine_phone_almost_usable/

      \n\n

      I've tried other desktop programs like Xournal and PPSSPP, their UIs also work well, I'd be able to do even more if OpenGL 3 was working.

      \n
      ", - "author": "InfiniteHawk", - "publication_date": "2020-07-17T22:35:14Z", - "url": "https://www.reddit.com/r/linux/comments/ht3d4k/blender_runs_on_linux_pinephone/", - "read": false, - "rule": 80, - "remote_identifier": "ht3d4k" - } -}, -{ - "model": "core.post", - "pk": 3138, - "fields": { - "created": "2020-07-21T20:14:50.415Z", - "modified": "2020-07-21T20:18:21.616Z", - "title": "Hrmmm They Need to Fix Throttle Animations in the Sabre", - "body": "
      ", - "author": "TheBootRanger", - "publication_date": "2020-07-21T13:26:01Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv5omc/hrmmm_they_need_to_fix_throttle_animations_in_the/", - "read": true, - "rule": 82, - "remote_identifier": "hv5omc" - } -}, -{ - "model": "core.post", - "pk": 3139, - "fields": { - "created": "2020-07-21T20:14:50.415Z", - "modified": "2020-07-21T20:18:49.999Z", - "title": "My first 3.10 landing could have gone better...", - "body": "
      ", - "author": "KnLfey", - "publication_date": "2020-07-21T16:04:50Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv7w85/my_first_310_landing_could_have_gone_better/", - "read": true, - "rule": 82, - "remote_identifier": "hv7w85" - } -}, -{ - "model": "core.post", - "pk": 3140, - "fields": { - "created": "2020-07-21T20:14:50.415Z", - "modified": "2020-07-21T20:14:50.439Z", - "title": "How about the Christmas in 3 more years?", - "body": "
      \"How
      ", - "author": "SpleanEater", - "publication_date": "2020-07-21T17:49:22Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv9qy8/how_about_the_christmas_in_3_more_years/", - "read": false, - "rule": 82, - "remote_identifier": "hv9qy8" - } -}, -{ - "model": "core.post", - "pk": 3141, - "fields": { - "created": "2020-07-21T20:14:50.415Z", - "modified": "2020-07-21T20:18:33.532Z", - "title": "Long time Elite Dangerous player. New to star citizen i think im doing great", - "body": "", - "author": "Filblo5", - "publication_date": "2020-07-21T15:33:49Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv7elb/long_time_elite_dangerous_player_new_to_star/", - "read": true, - "rule": 82, - "remote_identifier": "hv7elb" - } -}, -{ - "model": "core.post", - "pk": 3142, - "fields": { - "created": "2020-07-21T20:14:50.416Z", - "modified": "2020-07-21T20:14:50.443Z", - "title": "And we stand by it.", - "body": "
      \"And
      ", - "author": "CyberTill", - "publication_date": "2020-07-21T18:57:48Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hvb3wm/and_we_stand_by_it/", - "read": false, - "rule": 82, - "remote_identifier": "hvb3wm" - } -}, -{ - "model": "core.post", - "pk": 3143, - "fields": { - "created": "2020-07-21T20:14:50.416Z", - "modified": "2020-07-21T20:14:50.446Z", - "title": "Nomad", - "body": "
      \"Nomad\"
      ", - "author": "ibracitizen", - "publication_date": "2020-07-21T19:52:24Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hvc5h3/nomad/", - "read": false, - "rule": 82, - "remote_identifier": "hvc5h3" - } -}, -{ - "model": "core.post", - "pk": 3144, - "fields": { - "created": "2020-07-21T20:14:50.416Z", - "modified": "2020-07-21T20:14:50.449Z", - "title": "Probably the best screen cap i've ever caught on a whim. 3.5 Arc Corp release. Also a confession: I never pledged. Got a ship with my GPU. I intend to pay my dues.", - "body": "
      \"Probably
      ", - "author": "ScionoicS", - "publication_date": "2020-07-21T20:23:01Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hvcqzf/probably_the_best_screen_cap_ive_ever_caught_on_a/", - "read": false, - "rule": 82, - "remote_identifier": "hvcqzf" - } -}, -{ - "model": "core.post", - "pk": 3145, - "fields": { - "created": "2020-07-21T20:14:50.416Z", - "modified": "2020-07-21T20:14:50.451Z", - "title": "Play to escape the depressing job hunt where I need 10 years experience for a entry level job to find this, only been playing for 1 and a half years :(", - "body": "
      \"Play
      ", - "author": "Albert-III-", - "publication_date": "2020-07-21T12:23:45Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv4z08/play_to_escape_the_depressing_job_hunt_where_i/", - "read": false, - "rule": 82, - "remote_identifier": "hv4z08" - } -}, -{ - "model": "core.post", - "pk": 3146, - "fields": { - "created": "2020-07-21T20:14:50.416Z", - "modified": "2020-07-21T20:19:00.691Z", - "title": "The void beckons.", - "body": "
      ", - "author": "HisNameWasHis", - "publication_date": "2020-07-21T14:40:51Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv6nij/the_void_beckons/", - "read": true, - "rule": 82, - "remote_identifier": "hv6nij" - } -}, -{ - "model": "core.post", - "pk": 3147, - "fields": { - "created": "2020-07-21T20:14:50.416Z", - "modified": "2020-07-21T20:19:05.881Z", - "title": "I made a SC-like Photobash with Soldiers", - "body": "
      \"I
      ", - "author": "IsaacPolar", - "publication_date": "2020-07-21T17:13:39Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv92ri/i_made_a_sclike_photobash_with_soldiers/", - "read": true, - "rule": 82, - "remote_identifier": "hv92ri" - } -}, -{ - "model": "core.post", - "pk": 3148, - "fields": { - "created": "2020-07-21T20:14:50.416Z", - "modified": "2020-07-21T20:19:41.227Z", - "title": "Ocean Shader Improvements", - "body": "
      \"Ocean
      ", - "author": "shoeii", - "publication_date": "2020-07-21T18:41:51Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hvasds/ocean_shader_improvements/", - "read": true, - "rule": 82, - "remote_identifier": "hvasds" - } -}, -{ - "model": "core.post", - "pk": 3149, - "fields": { - "created": "2020-07-21T20:14:50.420Z", - "modified": "2020-07-21T20:14:50.459Z", - "title": "As much shit as Star Citizen (rightfully) gets it still does one thing better than any other 'game' I've played", - "body": "

      It invokes a real sense of scale, on multiple levels.

      \n\n

      One could argue that's one of the most important feelings you'd want to capture in any game set in space, but of course it's mostly meaningless if there aren't enough gameplay loops and systems in place to work in tandem with and make the space that's been created interesting, and that's where SC is currently a failure.

      \n\n

      Even so, I think being able to create that sense of smallness isn't insignificant.

      \n\n

      You as a pilot are dwarfed by your ship which is itself dwarfed by a larger ship which is itself dwarfed by another, even more massive one which is dwarfed by the space station or hub you're at which is dwarfed by a crater on a moon which is dwarfed by the moon itself which is dwarfed by the planet it orbits which is dwarfed by the sheer vastness of space in between all of those things and that they are, despite the distance, still connected.

      \n\n

      Getting lost in Lorville (even if it is mostly linear) and knowing it's only a small part of the playable space is a really neat feeling - looking out from the windows of the train up into the sky and knowing you can go there and beyond really makes you feel like there is a whole world (and more) waiting to be explored.

      \n\n

      I think this is a direct result of having legs and not being locked into the cockpit of your ship - I've played more Elite: Dangerous than Star Citizen and it accomplishes a similar sense of scale but, at least not as far as I've felt, never to the same degree - because you're locked in your ship you never really get this same sense of being small or insignificant even though you are dwarfed in similar ways by planets/asteroids/other ships - will be interesting to see how their implementation of 'space legs' in the upcoming expansion changes this.

      \n\n

      My favourite thing to do in Star Citizen (because there isn't a whole lot) is to just find some pocket of space far away from anything else and just walk around my ship, feeling truly alone and insignificant, gazing out at the void that stretches infinitely all around - something about that is super comfy.

      \n\n

      I can't think of many other game that accomplish a similar level of scale though I'm sure they exist.

      \n\n

      I've been playing an indie game called Empyrion - Galactic Survival and it actually is sort of similar to SC in this regard but it's nowhere near as polished or smooth - transitions from atmosphere to space are not truly seamless and planets themselves are kind of stitched together, but it still manages to invoke that same kind of awe at the scale of things when you dock a small vessel to a capital vessel, for example - definitely worth checking out if you like sci-fi/space games, which you must if you're here, but just be prepared for the jank.

      \n
      ", - "author": "thegreatself", - "publication_date": "2020-07-21T20:30:15Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hvcw38/as_much_shit_as_star_citizen_rightfully_gets_it/", - "read": false, - "rule": 82, - "remote_identifier": "hvcw38" - } -}, -{ - "model": "core.post", - "pk": 3150, - "fields": { - "created": "2020-07-21T20:14:50.420Z", - "modified": "2020-07-21T20:14:50.462Z", - "title": "You waiting for patch 3.10 to go live while watching tons of videos about the new flight model features. Be patient, 3.11 and 3.12 will be even better.", - "body": "
      \"You
      ", - "author": "jsabater76", - "publication_date": "2020-07-21T09:39:27Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv372v/you_waiting_for_patch_310_to_go_live_while/", - "read": false, - "rule": 82, - "remote_identifier": "hv372v" - } -}, -{ - "model": "core.post", - "pk": 3151, - "fields": { - "created": "2020-07-21T20:14:50.420Z", - "modified": "2020-07-21T20:14:50.466Z", - "title": "CIG, can we please fix these \"black hole\" doors(when they are closed) on ships please.", - "body": "
      \"CIG,
      ", - "author": "AbnormallyBendPenis", - "publication_date": "2020-07-21T13:40:14Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv5uzj/cig_can_we_please_fix_these_black_hole_doorswhen/", - "read": false, - "rule": 82, - "remote_identifier": "hv5uzj" - } -}, -{ - "model": "core.post", - "pk": 3152, - "fields": { - "created": "2020-07-21T20:14:50.420Z", - "modified": "2020-07-21T20:14:50.468Z", - "title": "Anvil Super Hornet over Cellin", - "body": "
      \"Anvil
      ", - "author": "SaraCaterina", - "publication_date": "2020-07-21T20:33:58Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hvcyq6/anvil_super_hornet_over_cellin/", - "read": false, - "rule": 82, - "remote_identifier": "hvcyq6" - } -}, -{ - "model": "core.post", - "pk": 3153, - "fields": { - "created": "2020-07-21T20:14:50.420Z", - "modified": "2020-07-21T20:14:50.471Z", - "title": "3.10 Combat Changes", - "body": "", - "author": "STLYoungblood", - "publication_date": "2020-07-21T16:37:44Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv8fr7/310_combat_changes/", - "read": false, - "rule": 82, - "remote_identifier": "hv8fr7" - } -}, -{ - "model": "core.post", - "pk": 3154, - "fields": { - "created": "2020-07-21T20:14:50.420Z", - "modified": "2020-07-21T20:14:50.472Z", - "title": "Hey CIG how about that S42 Vi.... Oh...", - "body": "
      \"Hey
      ", - "author": "SiEDeN", - "publication_date": "2020-07-21T21:37:16Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hve6am/hey_cig_how_about_that_s42_vi_oh/", - "read": false, - "rule": 82, - "remote_identifier": "hve6am" - } -}, -{ - "model": "core.post", - "pk": 3155, - "fields": { - "created": "2020-07-21T20:14:50.422Z", - "modified": "2020-07-21T20:14:50.475Z", - "title": "3.10 M PTU Eclipse improvements", - "body": "

      If this goes live, CIG had addressed 2 of my Eclipse critics.

      \n\n

      Not because of my videos of course, CIG doesn't know I exist.

      \n\n

       

      \n\n

      a. Eclipse has armor stealth in 3.10, see my table:\nhttps://docs.google.com/spreadsheets/d/1OJXg7MQsG_IVTPsmlmZYaxEPK4n4iqnhQx4oigIlJHg/edit#gid=343807746

      \n\n

       

      \n\n

      b. Eclipse can fire her size 9 torpedoes way quicker now, see my video with a side by side comparison of the max firing speed in 3.9 and 3.10:\nhttps://youtu.be/GFTF1Qt7T3o?t=207

      \n
      ", - "author": "Camural", - "publication_date": "2020-07-21T18:15:50Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hva9lc/310_m_ptu_eclipse_improvements/", - "read": false, - "rule": 82, - "remote_identifier": "hva9lc" - } -}, -{ - "model": "core.post", - "pk": 3156, - "fields": { - "created": "2020-07-21T20:14:50.422Z", - "modified": "2020-07-21T20:14:50.477Z", - "title": "Hark! The Drake Herald Sings", - "body": "
      \"Hark!
      ", - "author": "CyrexStorm", - "publication_date": "2020-07-21T16:19:31Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv84kk/hark_the_drake_herald_sings/", - "read": false, - "rule": 82, - "remote_identifier": "hv84kk" - } -}, -{ - "model": "core.post", - "pk": 3157, - "fields": { - "created": "2020-07-21T20:14:50.422Z", - "modified": "2020-07-21T20:14:50.479Z", - "title": "The new flight stick in the Prowler", - "body": "
      \"The
      ", - "author": "Potato_Nades", - "publication_date": "2020-07-21T16:22:22Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv86c2/the_new_flight_stick_in_the_prowler/", - "read": false, - "rule": 82, - "remote_identifier": "hv86c2" - } -}, -{ - "model": "core.post", - "pk": 3158, - "fields": { - "created": "2020-07-21T20:14:50.422Z", - "modified": "2020-07-21T20:14:50.481Z", - "title": "Norwegian VAT charged from August 1st", - "body": "
      \"Norwegian
      ", - "author": "norgeek", - "publication_date": "2020-07-21T10:30:57Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv3r3l/norwegian_vat_charged_from_august_1st/", - "read": false, - "rule": 82, - "remote_identifier": "hv3r3l" - } -}, -{ - "model": "core.post", - "pk": 3159, - "fields": { - "created": "2020-07-21T20:14:50.423Z", - "modified": "2020-07-21T20:14:50.484Z", - "title": "With Pyro (currently WIP), Nyx (partially done), Odin (S42), currently on the way, what is everyone\u2019s thoughts on Terra possibly being next on the list of star systems to be added into the PU within \u2026", - "body": "
      \"With
      ", - "author": "realCLTotaku", - "publication_date": "2020-07-21T13:27:09Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv5p41/with_pyro_currently_wip_nyx_partially_done_odin/", - "read": false, - "rule": 82, - "remote_identifier": "hv5p41" - } -}, -{ - "model": "core.post", - "pk": 3160, - "fields": { - "created": "2020-07-21T20:14:50.423Z", - "modified": "2020-07-21T20:14:50.486Z", - "title": "Testing out the new electron rifle", - "body": "
      ", - "author": "joshbaker2112", - "publication_date": "2020-07-21T02:56:19Z", - "url": "https://www.reddit.com/r/starcitizen/comments/huxr6d/testing_out_the_new_electron_rifle/", - "read": false, - "rule": 82, - "remote_identifier": "huxr6d" - } -}, -{ - "model": "core.post", - "pk": 3161, - "fields": { - "created": "2020-07-21T20:14:50.423Z", - "modified": "2020-07-21T20:14:50.487Z", - "title": "Imperial Geographic's Lovecraftian magazine special is here. \ud83d\udc19 Find the link in the comments!", - "body": "
      \"Imperial
      ", - "author": "Good_Punk2", - "publication_date": "2020-07-21T18:21:38Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hvadrh/imperial_geographics_lovecraftian_magazine/", - "read": false, - "rule": 82, - "remote_identifier": "hvadrh" - } -}, -{ - "model": "core.post", - "pk": 3162, - "fields": { - "created": "2020-07-21T20:14:50.497Z", - "modified": "2020-07-21T20:14:50.525Z", - "title": "Linux Distributions Timeline", - "body": "
      \"Linux
      ", - "author": "bauripalash", - "publication_date": "2020-07-21T06:07:59Z", - "url": "https://www.reddit.com/r/linux/comments/hv0ktn/linux_distributions_timeline/", - "read": false, - "rule": 80, - "remote_identifier": "hv0ktn" - } -}, -{ - "model": "core.post", - "pk": 3163, - "fields": { - "created": "2020-07-21T20:14:50.497Z", - "modified": "2020-07-21T20:14:50.527Z", - "title": "Fedora: Proposal to replace default wined3d backend with DXVK", - "body": "", - "author": "friskfrugt", - "publication_date": "2020-07-21T19:42:49Z", - "url": "https://www.reddit.com/r/linux/comments/hvbyyr/fedora_proposal_to_replace_default_wined3d/", - "read": false, - "rule": 80, - "remote_identifier": "hvbyyr" - } -}, -{ - "model": "core.post", - "pk": 3164, - "fields": { - "created": "2020-07-21T20:14:50.497Z", - "modified": "2020-07-21T20:14:50.531Z", - "title": "Update on marketing and communication plans for the LibreOffice 7.x series", - "body": "", - "author": "TheQuantumZero", - "publication_date": "2020-07-21T09:59:23Z", - "url": "https://www.reddit.com/r/linux/comments/hv3erm/update_on_marketing_and_communication_plans_for/", - "read": false, - "rule": 80, - "remote_identifier": "hv3erm" - } -}, -{ - "model": "core.post", - "pk": 3165, - "fields": { - "created": "2020-07-21T20:14:50.497Z", - "modified": "2020-07-21T20:14:50.533Z", - "title": "FOSS job opening: LibreOffice Development Mentor at The Document Foundation", - "body": "", - "author": "themikeosguy", - "publication_date": "2020-07-21T14:26:36Z", - "url": "https://www.reddit.com/r/linux/comments/hv6gfw/foss_job_opening_libreoffice_development_mentor/", - "read": false, - "rule": 80, - "remote_identifier": "hv6gfw" - } -}, -{ - "model": "core.post", - "pk": 3166, - "fields": { - "created": "2020-07-21T20:14:50.503Z", - "modified": "2020-07-21T20:14:50.536Z", - "title": "gomd - quickly display formatted markdown files with code highlight in your browser", - "body": "

      Hi all!

      \n\n

      I wanted to share a project I've been working on recently. I think it reached a stage where it's pretty usable and should work out of the box. gomd sets up a HTTP server and serves a directory in your browser so you can quickly view your markdown files. It comes with some neat features like:

      \n\n
        \n
      • Monitoring files - it will monitor files for changes and reload them whenever needed
      • \n
      • Hot reloading - whenever the file you are currently viewing changes, the tab in your browser will reload automatically.
      • \n
      • Code Highlight - All blocks of code in most common languages will be color highlighted.
      • \n
      • Themes - choose from multiple themes like: solarized, monokai, github, dracula...
      • \n
      \n\n

      Link: gomd

      \n\n

      For now its only available from AUR or built from source.

      \n\n

      \n\n

      Any tips or feedback will be greatly appreciated :)

      \n
      ", - "author": "wwojtekk", - "publication_date": "2020-07-21T20:07:31Z", - "url": "https://www.reddit.com/r/linux/comments/hvcg44/gomd_quickly_display_formatted_markdown_files/", - "read": false, - "rule": 80, - "remote_identifier": "hvcg44" - } -}, -{ - "model": "core.post", - "pk": 3167, - "fields": { - "created": "2020-07-21T20:14:50.503Z", - "modified": "2020-07-21T20:14:50.543Z", - "title": "They're not otherwise wrong, but it didn't become a real Internet standard until 2017.", - "body": "
      \"They're
      ", - "author": "foodown", - "publication_date": "2020-07-21T21:39:09Z", - "url": "https://www.reddit.com/r/linux/comments/hve7l5/theyre_not_otherwise_wrong_but_it_didnt_become_a/", - "read": false, - "rule": 80, - "remote_identifier": "hve7l5" - } -}, -{ - "model": "core.post", - "pk": 3168, - "fields": { - "created": "2020-07-21T20:14:50.503Z", - "modified": "2020-07-21T20:14:50.545Z", - "title": "Drawing - an alternative to Paint for Linux (gtk3, support HiDPI)", - "body": "", - "author": "dontdieych", - "publication_date": "2020-07-21T02:37:22Z", - "url": "https://www.reddit.com/r/linux/comments/huxgsg/drawing_an_alternative_to_paint_for_linux_gtk3/", - "read": false, - "rule": 80, - "remote_identifier": "huxgsg" - } -}, -{ - "model": "core.post", - "pk": 3169, - "fields": { - "created": "2020-07-21T20:14:50.509Z", - "modified": "2020-07-21T20:14:50.547Z", - "title": "Observations on a Linux issue with 3.5mm earphones with a mic", - "body": "

      Alright hello. I have come from r/SolusProject and I made a post there to do with headphone issues. I suggest you read through the post and comments to get a better understanding before reading this https://www.reddit.com/r/SolusProject/comments/hsql4d/frustrating_headphone_issues/. I had posted to do with it again, but it got taken down for duplication (when it wasn't duplication). This post is more of my observations from experimenting and such. There are distros I haven't tried but I tried a wide range of distros like manjaro, ubuntu based ones and all solus flavors, and I was looking more for how well they worked out of the box, rather than with fiddling around with pulse, hdajack etc which I know will work eventually. If you stumbled across this from searching about the same issue I have (or similar) or are confused to what this is about, I suggest you look at my previous post also.

      \n\n

      So anyways, I've tried the past few days mounting isos to usb drives and trying live os and installing various distros to see about the headphone issue. And my conclusion is that this issue affects the linux kernel in some way across the board. I don't really understand why completely but I have some kind of idea.

      \n\n

      From installing fresh distros, I noticed that the earphones (they are 3.5mm earphones + mic) get recognised as a microphone and not as a speaker system of some kind. Every single time I had a look at the sound settings and in pulse, they came up as plugged microphone, with the internal speakers being the only output device every single time. It's really odd seeing as how ubuntu 14.04 and xubuntu etc from years past worked flawlessly with the earphones, even manjaro a while ago on my older craptop worked fine. I don't really understand why it doesn't work on my device now.

      \n\n

      I'll leave my specs at the bottom of this post but what I think is is there's something the manufacturer did, or something like the cpu causes issue with linux. The manufacturer of my laptop is Lenovo, and the cpu/igpu is from AMD. A warning sign is that when installing a linux distro, it doesn't bring up the dual boot menu at startup like it should. Instead it completely hides the fact it exists until I use something like easyuefi to add an option for that distro, how it works is you specify the boot partition, whether it's linux or windows and the loader conf file for the distro. All of this hassle everytime doesn't appear on my craptop, because the dual boot menu appears flawlessly without issue. May be because it uses an Intel cpu/igpu unlike my newer laptop but it's hard to say.

      \n\n

      Also, it seems like the devices that appear in a given distro when looking at alsa, is hd generic devices but by reloading alsa or any command that shows the full name of the device, it says it's Intel. I don't know if that would be an issue, maybe amd use intel sound drivers or something. It's odd nonetheless.

      \n\n

      This issue has been boggling my mind for obvious reasons, with half-rhetorical questions like does linux not support the earphones anymore, whether out of accident from an overlooked bug in an update or intentionally phasing out? Is any of this AMD or Lenovo's fault? Even with proper headphones or something, will they fail? I don't think anyone here really knows, hell I'd bet an extreme that no one really understands why in the linux community. I kinda rambled in this post with stuff that should've been said in the last post/thread, but I'm saying it now.

      \n\n

      Thanks for contributing thus far to this discussion in figuring this out.

      \n\n

      Specs: AMD Ryzen 5 3500U Mobile CPU (2.2 - 3.7ghz quad core)

      \n\n

      Radeon Vega 8 Integrated GPU, 8GB Ram, 256GB SSD.

      \n\n

      Lenovo C340-14API Laptop

      \n
      ", - "author": "BrianMeerkatlol", - "publication_date": "2020-07-21T21:02:19Z", - "url": "https://www.reddit.com/r/linux/comments/hvdi3o/observations_on_a_linux_issue_with_35mm_earphones/", - "read": false, - "rule": 80, - "remote_identifier": "hvdi3o" - } -}, -{ - "model": "core.post", - "pk": 3170, - "fields": { - "created": "2020-07-21T20:14:50.509Z", - "modified": "2020-07-21T20:14:50.549Z", - "title": "South Korean distro HamoniKR OS has been added to Distrowatch", - "body": "", - "author": "TheHordeRisesAgain", - "publication_date": "2020-07-21T07:44:21Z", - "url": "https://www.reddit.com/r/linux/comments/hv1ug1/south_korean_distro_hamonikr_os_has_been_added_to/", - "read": false, - "rule": 80, - "remote_identifier": "hv1ug1" - } -}, -{ - "model": "core.post", - "pk": 3171, - "fields": { - "created": "2020-07-21T20:14:50.509Z", - "modified": "2020-07-21T20:14:50.559Z", - "title": "The Jailer is free! New release of the outstanding database subsetter and browser is available.", - "body": "", - "author": "Plane-Discussion", - "publication_date": "2020-07-21T12:53:54Z", - "url": "https://www.reddit.com/r/linux/comments/hv5b0j/the_jailer_is_free_new_release_of_the_outstanding/", - "read": false, - "rule": 80, - "remote_identifier": "hv5b0j" - } -}, -{ - "model": "core.post", - "pk": 3172, - "fields": { - "created": "2020-07-21T20:14:50.513Z", - "modified": "2020-07-21T20:14:50.563Z", - "title": "A few very well-aged excerpts from Microsoft\u2019s infamous 2004 \u201cGet the facts\u201d campaign, where they make the case for Windows servers being cheaper, more secure, and more performant than Linux servers", - "body": "
      \n

      Get the facts on Windows and Linux.

      \n\n

      Leading companies and third-party analysts confirm it: Windows has a lower total cost of ownership and outperforms Linux.

      \n\n

      ...

      \n\n

      -Security

      \n\n

      Windows Users Have Fewer Vulnerabilities

      \n
      \n\n

      And then literally the very next bullet point:

      \n\n
      \n

      -Featured Customer Case Study

      \n\n

      Equifax

      \n\n

      Equifax Sees 14 Percent Cost Savings

      \n\n

      Find out why Equifax, a global leader in transforming data into intelligence, selected Windows over Linux to enhance the speed and performance of its marketing services capabilities. Using Microsoft Windows Server System, the company has seen 14 percent in cost savings over Linux.

      \n
      \n\n

      Good thing they saved 14% and got all that extra security! Sure their website is janky and their login flow is downright horrifying (Check it out if you want to be amazed), but who could blame them? Linux is \u201cProhibitively Expensive, Extremely Complex, and Provides No Tangible Business Gains\u201d, Microsoft said so!

      \n\n

      Source: https://web.archive.org/web/20041027003759/http://www.microsoft.com/windowsserversystem/facts/default.mspx

      \n
      ", - "author": "kevinhaze", - "publication_date": "2020-07-20T21:42:15Z", - "url": "https://www.reddit.com/r/linux/comments/hus5lz/a_few_very_wellaged_excerpts_from_microsofts/", - "read": false, - "rule": 80, - "remote_identifier": "hus5lz" - } -}, -{ - "model": "core.post", - "pk": 3173, - "fields": { - "created": "2020-07-21T20:14:50.515Z", - "modified": "2020-07-21T20:14:50.566Z", - "title": "Are there are any professional audio recording studios or artists that use Linux?", - "body": "

      As the title says, who is using Linux as a professional audio engineer, producer, or artist? I am a former Mac user myself, and I am seeing people from time to time who have become disillusioned with what Apple has been doing for the past few years. However, I'm not sure if Linux really has a place for these people to land if they are serious about what they do.

      \n\n

      Fedora Design Suite and Ubuntu Studio are definitely encouraging to see, but what is their real-world usage like? Are we getting better with professional audio in Linux, or have things been stagnant for years?

      \n
      ", - "author": "RootHouston", - "publication_date": "2020-07-21T00:08:26Z", - "url": "https://www.reddit.com/r/linux/comments/huuxvq/are_there_are_any_professional_audio_recording/", - "read": false, - "rule": 80, - "remote_identifier": "huuxvq" - } -}, -{ - "model": "core.post", - "pk": 3174, - "fields": { - "created": "2020-07-21T20:14:50.515Z", - "modified": "2020-07-21T20:14:50.570Z", - "title": "When Linux had marketing", - "body": "", - "author": "Commodore256", - "publication_date": "2020-07-21T14:03:56Z", - "url": "https://www.reddit.com/r/linux/comments/hv65oa/when_linux_had_marketing/", - "read": false, - "rule": 80, - "remote_identifier": "hv65oa" - } -}, -{ - "model": "core.post", - "pk": 3175, - "fields": { - "created": "2020-07-21T20:14:50.520Z", - "modified": "2020-07-21T20:14:50.598Z", - "title": "Ward: Simple and minimalistic server dashboard", - "body": "

      Ward is a simple and and minimalistic server monitoring tool. Ward supports adaptive design system. Also it supports dark theme. It shows only principal information and can be used, if you want to see nice looking dashboard instead looking on bunch of numbers and graphs. Ward works nice on all popular operating systems, because it uses OSHI.

      \n\n

      https://preview.redd.it/gdppswc3a3c51.png?width=1448&format=png&auto=webp&s=0d6e10146c105ddcfd045dd59c970d4c127ddb8c

      \n\n

      https://github.com/B-Software/Ward

      \n
      ", - "author": "Pabyzu", - "publication_date": "2020-07-21T00:33:40Z", - "url": "https://www.reddit.com/r/linux/comments/huvea3/ward_simple_and_minimalistic_server_dashboard/", - "read": false, - "rule": 80, - "remote_identifier": "huvea3" - } -}, -{ - "model": "core.post", - "pk": 3176, - "fields": { - "created": "2020-07-21T20:14:50.522Z", - "modified": "2020-07-21T20:14:50.606Z", - "title": "WindowsFX - a good Windows alternative?", - "body": "

      I would personally like to hear some of your opinions (in the replies) about WindowsFX. What is WindowsFX you may ask? WindowsFX is a Brazilian linux distribution that is designed to look and act like Windows 10.

      \n\n

      Linux / WindowsFX is based off of Ubuntu, and uses Cinnamon as its DE. Upon first boot, normal Windows users can tell the difference. But if you were to put it in front of a non tech-savvy person, they wouldn't be able to tell the difference.

      \n\n

      Personally, with WSL on Windows, I see no need for a distro like this. However, as I said, I would like to hear your opinions on this distro.

      \n\n

      Video review here.

      \n
      ", - "author": "Demonitized101", - "publication_date": "2020-07-20T23:03:29Z", - "url": "https://www.reddit.com/r/linux/comments/hutpt5/windowsfx_a_good_windows_alternative/", - "read": false, - "rule": 80, - "remote_identifier": "hutpt5" - } -}, -{ - "model": "core.post", - "pk": 3177, - "fields": { - "created": "2020-07-21T20:14:50.775Z", - "modified": "2020-07-21T20:14:50.780Z", - "title": "Every day this good boy brings a carrot to his best buddy", - "body": "
      ", - "author": "TooShiftyForYou", - "publication_date": "2020-07-21T15:25:31Z", - "url": "https://www.reddit.com/r/aww/comments/hv7a8b/every_day_this_good_boy_brings_a_carrot_to_his/", - "read": false, - "rule": 81, - "remote_identifier": "hv7a8b" - } -}, -{ - "model": "core.post", - "pk": 3178, - "fields": { - "created": "2020-07-21T20:14:50.775Z", - "modified": "2020-07-25T20:08:34.264Z", - "title": "Kitten mimics his human petting the dog", - "body": "
      ", - "author": "SpecterAscendant", - "publication_date": "2020-07-21T14:56:57Z", - "url": "https://www.reddit.com/r/aww/comments/hv6ve3/kitten_mimics_his_human_petting_the_dog/", - "read": true, - "rule": 81, - "remote_identifier": "hv6ve3" - } -}, -{ - "model": "core.post", - "pk": 3179, - "fields": { - "created": "2020-07-21T20:14:50.775Z", - "modified": "2020-07-21T20:14:50.789Z", - "title": "My fox friend!", - "body": "
      ", - "author": "Zepantha", - "publication_date": "2020-07-21T14:27:25Z", - "url": "https://www.reddit.com/r/aww/comments/hv6gte/my_fox_friend/", - "read": false, - "rule": 81, - "remote_identifier": "hv6gte" - } -}, -{ - "model": "core.post", - "pk": 3180, - "fields": { - "created": "2020-07-21T20:14:50.775Z", - "modified": "2020-07-21T20:15:46.876Z", - "title": "Ducks annihilate peas", - "body": "
      ", - "author": "tommycalibre", - "publication_date": "2020-07-21T17:12:40Z", - "url": "https://www.reddit.com/r/aww/comments/hv9258/ducks_annihilate_peas/", - "read": true, - "rule": 81, - "remote_identifier": "hv9258" - } -}, -{ - "model": "core.post", - "pk": 3181, - "fields": { - "created": "2020-07-21T20:14:50.775Z", - "modified": "2020-07-21T20:14:50.797Z", - "title": "Wiggle it baby", - "body": "
      ", - "author": "neo_star", - "publication_date": "2020-07-21T18:44:31Z", - "url": "https://www.reddit.com/r/aww/comments/hvaucy/wiggle_it_baby/", - "read": false, - "rule": 81, - "remote_identifier": "hvaucy" - } -}, -{ - "model": "core.post", - "pk": 3182, - "fields": { - "created": "2020-07-21T20:14:50.776Z", - "modified": "2020-07-21T20:16:22.725Z", - "title": "I guess I should do this.. everyone seems to be liking little pups and kittens so.. Reddit, meet bailey", - "body": "
      \"I
      ", - "author": "X_XNOTHINGX_X", - "publication_date": "2020-07-21T14:15:08Z", - "url": "https://www.reddit.com/r/aww/comments/hv6b0a/i_guess_i_should_do_this_everyone_seems_to_be/", - "read": true, - "rule": 81, - "remote_identifier": "hv6b0a" - } -}, -{ - "model": "core.post", - "pk": 3183, - "fields": { - "created": "2020-07-21T20:14:50.776Z", - "modified": "2020-07-21T20:14:50.806Z", - "title": "The hat makes the crab.", - "body": "
      \"The
      ", - "author": "fujfuj", - "publication_date": "2020-07-21T14:48:40Z", - "url": "https://www.reddit.com/r/aww/comments/hv6rde/the_hat_makes_the_crab/", - "read": false, - "rule": 81, - "remote_identifier": "hv6rde" - } -}, -{ - "model": "core.post", - "pk": 3184, - "fields": { - "created": "2020-07-21T20:14:50.776Z", - "modified": "2020-07-21T20:14:50.812Z", - "title": "Baby bunny fits in hand", - "body": "
      ", - "author": "Hawken10", - "publication_date": "2020-07-21T12:31:30Z", - "url": "https://www.reddit.com/r/aww/comments/hv5253/baby_bunny_fits_in_hand/", - "read": false, - "rule": 81, - "remote_identifier": "hv5253" - } -}, -{ - "model": "core.post", - "pk": 3185, - "fields": { - "created": "2020-07-21T20:14:50.776Z", - "modified": "2020-07-21T20:14:50.818Z", - "title": "My cat and I, both pregnant", - "body": "
      \"My
      ", - "author": "nixdionisio", - "publication_date": "2020-07-21T11:06:25Z", - "url": "https://www.reddit.com/r/aww/comments/hv44m2/my_cat_and_i_both_pregnant/", - "read": false, - "rule": 81, - "remote_identifier": "hv44m2" - } -}, -{ - "model": "core.post", - "pk": 3186, - "fields": { - "created": "2020-07-21T20:14:50.776Z", - "modified": "2020-07-21T20:14:50.822Z", - "title": "Very sweet dance", - "body": "
      ", - "author": "Ashley1023", - "publication_date": "2020-07-21T13:03:03Z", - "url": "https://www.reddit.com/r/aww/comments/hv5ewq/very_sweet_dance/", - "read": false, - "rule": 81, - "remote_identifier": "hv5ewq" - } -}, -{ - "model": "core.post", - "pk": 3187, - "fields": { - "created": "2020-07-21T20:14:50.776Z", - "modified": "2020-07-21T20:14:50.825Z", - "title": "My local pet-store has a cat named Vegemite \u2764\ufe0f", - "body": "
      \"My
      ", - "author": "galinhad", - "publication_date": "2020-07-21T12:06:17Z", - "url": "https://www.reddit.com/r/aww/comments/hv4s5z/my_local_petstore_has_a_cat_named_vegemite/", - "read": false, - "rule": 81, - "remote_identifier": "hv4s5z" - } -}, -{ - "model": "core.post", - "pk": 3188, - "fields": { - "created": "2020-07-21T20:14:50.777Z", - "modified": "2020-07-21T20:15:01.459Z", - "title": "A teacher like that makes a huge difference", - "body": "
      ", - "author": "Unicornglitteryblood", - "publication_date": "2020-07-21T18:29:57Z", - "url": "https://www.reddit.com/r/aww/comments/hvajo9/a_teacher_like_that_makes_a_huge_difference/", - "read": true, - "rule": 81, - "remote_identifier": "hvajo9" - } -}, -{ - "model": "core.post", - "pk": 3189, - "fields": { - "created": "2020-07-21T20:14:50.777Z", - "modified": "2020-07-22T19:55:49.930Z", - "title": "Kitten Encounters Bubbly Water", - "body": "
      \"Kitten
      ", - "author": "DragonOBunny", - "publication_date": "2020-07-21T15:28:05Z", - "url": "https://www.reddit.com/r/aww/comments/hv7bis/kitten_encounters_bubbly_water/", - "read": true, - "rule": 81, - "remote_identifier": "hv7bis" - } -}, -{ - "model": "core.post", - "pk": 3190, - "fields": { - "created": "2020-07-21T20:14:50.777Z", - "modified": "2020-07-21T20:14:50.833Z", - "title": "Are These My Chickens Now?", - "body": "", - "author": "jasontaken", - "publication_date": "2020-07-21T09:55:36Z", - "url": "https://www.reddit.com/r/aww/comments/hv3de1/are_these_my_chickens_now/", - "read": false, - "rule": 81, - "remote_identifier": "hv3de1" - } -}, -{ - "model": "core.post", - "pk": 3191, - "fields": { - "created": "2020-07-21T20:14:50.777Z", - "modified": "2020-07-25T20:08:20.518Z", - "title": "Our St Bernard 6 months apart", - "body": "
      \"Our
      ", - "author": "ryan3105", - "publication_date": "2020-07-21T18:00:04Z", - "url": "https://www.reddit.com/r/aww/comments/hv9yea/our_st_bernard_6_months_apart/", - "read": true, - "rule": 81, - "remote_identifier": "hv9yea" - } -}, -{ - "model": "core.post", - "pk": 3192, - "fields": { - "created": "2020-07-21T20:14:50.777Z", - "modified": "2020-07-21T20:14:50.837Z", - "title": "Father and child in sync", - "body": "
      ", - "author": "Araragi_Monogatari", - "publication_date": "2020-07-21T08:29:18Z", - "url": "https://www.reddit.com/r/aww/comments/hv2enj/father_and_child_in_sync/", - "read": false, - "rule": 81, - "remote_identifier": "hv2enj" - } -}, -{ - "model": "core.post", - "pk": 3193, - "fields": { - "created": "2020-07-21T20:14:50.778Z", - "modified": "2020-07-21T20:14:50.840Z", - "title": "A meme is born", - "body": "
      \"A
      ", - "author": "Unicornglitteryblood", - "publication_date": "2020-07-21T18:55:04Z", - "url": "https://www.reddit.com/r/aww/comments/hvb1vh/a_meme_is_born/", - "read": false, - "rule": 81, - "remote_identifier": "hvb1vh" - } -}, -{ - "model": "core.post", - "pk": 3194, - "fields": { - "created": "2020-07-21T20:14:50.778Z", - "modified": "2020-07-21T20:14:50.842Z", - "title": "She bites, then she sleeps, then bites again, then sleeps again. \ud83d\ude02", - "body": "
      ", - "author": "earlymauvs", - "publication_date": "2020-07-21T11:34:19Z", - "url": "https://www.reddit.com/r/aww/comments/hv4fat/she_bites_then_she_sleeps_then_bites_again_then/", - "read": false, - "rule": 81, - "remote_identifier": "hv4fat" - } -}, -{ - "model": "core.post", - "pk": 3195, - "fields": { - "created": "2020-07-21T20:14:50.778Z", - "modified": "2020-07-21T20:14:50.844Z", - "title": "Nothing calmer that 2 ginger cats rubbing heads and showing their love in morning", - "body": "
      \"Nothing
      ", - "author": "Apotheosis33", - "publication_date": "2020-07-21T08:39:24Z", - "url": "https://www.reddit.com/r/aww/comments/hv2j2g/nothing_calmer_that_2_ginger_cats_rubbing_heads/", - "read": false, - "rule": 81, - "remote_identifier": "hv2j2g" - } -}, -{ - "model": "core.post", - "pk": 3196, - "fields": { - "created": "2020-07-21T20:14:50.778Z", - "modified": "2020-07-21T20:14:50.851Z", - "title": "Ring Tailed Possum", - "body": "", - "author": "Wayward-Delver", - "publication_date": "2020-07-21T11:23:51Z", - "url": "https://www.reddit.com/r/aww/comments/hv4b9e/ring_tailed_possum/", - "read": false, - "rule": 81, - "remote_identifier": "hv4b9e" - } -}, -{ - "model": "core.post", - "pk": 3197, - "fields": { - "created": "2020-07-21T20:14:50.778Z", - "modified": "2020-07-21T20:14:50.854Z", - "title": "Baby scooby in sad mood....", - "body": "
      \"Baby
      ", - "author": "deepanshuahiroo7", - "publication_date": "2020-07-21T15:12:23Z", - "url": "https://www.reddit.com/r/aww/comments/hv73ft/baby_scooby_in_sad_mood/", - "read": false, - "rule": 81, - "remote_identifier": "hv73ft" - } -}, -{ - "model": "core.post", - "pk": 3198, - "fields": { - "created": "2020-07-21T20:14:50.779Z", - "modified": "2020-07-21T20:14:50.856Z", - "title": "New friends!", - "body": "
      \"New
      ", - "author": "HelentotheKeller", - "publication_date": "2020-07-21T13:10:48Z", - "url": "https://www.reddit.com/r/aww/comments/hv5i6i/new_friends/", - "read": false, - "rule": 81, - "remote_identifier": "hv5i6i" - } -}, -{ - "model": "core.post", - "pk": 3199, - "fields": { - "created": "2020-07-21T20:14:50.779Z", - "modified": "2020-07-21T20:14:50.858Z", - "title": "When you haven't chewed anything for 1 second", - "body": "
      \"When
      ", - "author": "Tanay4", - "publication_date": "2020-07-21T10:26:53Z", - "url": "https://www.reddit.com/r/aww/comments/hv3pl0/when_you_havent_chewed_anything_for_1_second/", - "read": false, - "rule": 81, - "remote_identifier": "hv3pl0" - } -}, -{ - "model": "core.post", - "pk": 3200, - "fields": { - "created": "2020-07-21T20:14:50.779Z", - "modified": "2020-07-21T20:17:01.490Z", - "title": "Mango Derp", - "body": "
      \"Mango
      ", - "author": "sheetglass", - "publication_date": "2020-07-21T13:27:26Z", - "url": "https://www.reddit.com/r/aww/comments/hv5p8s/mango_derp/", - "read": true, - "rule": 81, - "remote_identifier": "hv5p8s" - } -}, -{ - "model": "core.post", - "pk": 3201, - "fields": { - "created": "2020-07-21T20:14:50.779Z", - "modified": "2020-07-21T20:14:50.863Z", - "title": "My guy turns 20 next month", - "body": "
      \"My
      ", - "author": "alozsoc", - "publication_date": "2020-07-21T06:34:26Z", - "url": "https://www.reddit.com/r/aww/comments/hv0xp1/my_guy_turns_20_next_month/", - "read": false, - "rule": 81, - "remote_identifier": "hv0xp1" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add log entry", - "content_type": [ - "admin", - "logentry" - ], - "codename": "add_logentry" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change log entry", - "content_type": [ - "admin", - "logentry" - ], - "codename": "change_logentry" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete log entry", - "content_type": [ - "admin", - "logentry" - ], - "codename": "delete_logentry" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view log entry", - "content_type": [ - "admin", - "logentry" - ], - "codename": "view_logentry" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add permission", - "content_type": [ - "auth", - "permission" - ], - "codename": "add_permission" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change permission", - "content_type": [ - "auth", - "permission" - ], - "codename": "change_permission" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete permission", - "content_type": [ - "auth", - "permission" - ], - "codename": "delete_permission" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view permission", - "content_type": [ - "auth", - "permission" - ], - "codename": "view_permission" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add group", - "content_type": [ - "auth", - "group" - ], - "codename": "add_group" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change group", - "content_type": [ - "auth", - "group" - ], - "codename": "change_group" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete group", - "content_type": [ - "auth", - "group" - ], - "codename": "delete_group" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view group", - "content_type": [ - "auth", - "group" - ], - "codename": "view_group" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add content type", - "content_type": [ - "contenttypes", - "contenttype" - ], - "codename": "add_contenttype" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change content type", - "content_type": [ - "contenttypes", - "contenttype" - ], - "codename": "change_contenttype" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete content type", - "content_type": [ - "contenttypes", - "contenttype" - ], - "codename": "delete_contenttype" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view content type", - "content_type": [ - "contenttypes", - "contenttype" - ], - "codename": "view_contenttype" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add session", - "content_type": [ - "sessions", - "session" - ], - "codename": "add_session" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change session", - "content_type": [ - "sessions", - "session" - ], - "codename": "change_session" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete session", - "content_type": [ - "sessions", - "session" - ], - "codename": "delete_session" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view session", - "content_type": [ - "sessions", - "session" - ], - "codename": "view_session" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add crontab", - "content_type": [ - "django_celery_beat", - "crontabschedule" - ], - "codename": "add_crontabschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change crontab", - "content_type": [ - "django_celery_beat", - "crontabschedule" - ], - "codename": "change_crontabschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete crontab", - "content_type": [ - "django_celery_beat", - "crontabschedule" - ], - "codename": "delete_crontabschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view crontab", - "content_type": [ - "django_celery_beat", - "crontabschedule" - ], - "codename": "view_crontabschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add interval", - "content_type": [ - "django_celery_beat", - "intervalschedule" - ], - "codename": "add_intervalschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change interval", - "content_type": [ - "django_celery_beat", - "intervalschedule" - ], - "codename": "change_intervalschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete interval", - "content_type": [ - "django_celery_beat", - "intervalschedule" - ], - "codename": "delete_intervalschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view interval", - "content_type": [ - "django_celery_beat", - "intervalschedule" - ], - "codename": "view_intervalschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add periodic task", - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "codename": "add_periodictask" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change periodic task", - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "codename": "change_periodictask" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete periodic task", - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "codename": "delete_periodictask" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view periodic task", - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "codename": "view_periodictask" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add periodic tasks", - "content_type": [ - "django_celery_beat", - "periodictasks" - ], - "codename": "add_periodictasks" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change periodic tasks", - "content_type": [ - "django_celery_beat", - "periodictasks" - ], - "codename": "change_periodictasks" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete periodic tasks", - "content_type": [ - "django_celery_beat", - "periodictasks" - ], - "codename": "delete_periodictasks" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view periodic tasks", - "content_type": [ - "django_celery_beat", - "periodictasks" - ], - "codename": "view_periodictasks" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add solar event", - "content_type": [ - "django_celery_beat", - "solarschedule" - ], - "codename": "add_solarschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change solar event", - "content_type": [ - "django_celery_beat", - "solarschedule" - ], - "codename": "change_solarschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete solar event", - "content_type": [ - "django_celery_beat", - "solarschedule" - ], - "codename": "delete_solarschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view solar event", - "content_type": [ - "django_celery_beat", - "solarschedule" - ], - "codename": "view_solarschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add clocked", - "content_type": [ - "django_celery_beat", - "clockedschedule" - ], - "codename": "add_clockedschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change clocked", - "content_type": [ - "django_celery_beat", - "clockedschedule" - ], - "codename": "change_clockedschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete clocked", - "content_type": [ - "django_celery_beat", - "clockedschedule" - ], - "codename": "delete_clockedschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view clocked", - "content_type": [ - "django_celery_beat", - "clockedschedule" - ], - "codename": "view_clockedschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add registration profile", - "content_type": [ - "registration", - "registrationprofile" - ], - "codename": "add_registrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change registration profile", - "content_type": [ - "registration", - "registrationprofile" - ], - "codename": "change_registrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete registration profile", - "content_type": [ - "registration", - "registrationprofile" - ], - "codename": "delete_registrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view registration profile", - "content_type": [ - "registration", - "registrationprofile" - ], - "codename": "view_registrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add supervised registration profile", - "content_type": [ - "registration", - "supervisedregistrationprofile" - ], - "codename": "add_supervisedregistrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change supervised registration profile", - "content_type": [ - "registration", - "supervisedregistrationprofile" - ], - "codename": "change_supervisedregistrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete supervised registration profile", - "content_type": [ - "registration", - "supervisedregistrationprofile" - ], - "codename": "delete_supervisedregistrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view supervised registration profile", - "content_type": [ - "registration", - "supervisedregistrationprofile" - ], - "codename": "view_supervisedregistrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add access attempt", - "content_type": [ - "axes", - "accessattempt" - ], - "codename": "add_accessattempt" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change access attempt", - "content_type": [ - "axes", - "accessattempt" - ], - "codename": "change_accessattempt" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete access attempt", - "content_type": [ - "axes", - "accessattempt" - ], - "codename": "delete_accessattempt" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view access attempt", - "content_type": [ - "axes", - "accessattempt" - ], - "codename": "view_accessattempt" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add access log", - "content_type": [ - "axes", - "accesslog" - ], - "codename": "add_accesslog" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change access log", - "content_type": [ - "axes", - "accesslog" - ], - "codename": "change_accesslog" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete access log", - "content_type": [ - "axes", - "accesslog" - ], - "codename": "delete_accesslog" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view access log", - "content_type": [ - "axes", - "accesslog" - ], - "codename": "view_accesslog" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add user", - "content_type": [ - "accounts", - "user" - ], - "codename": "add_user" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change user", - "content_type": [ - "accounts", - "user" - ], - "codename": "change_user" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete user", - "content_type": [ - "accounts", - "user" - ], - "codename": "delete_user" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view user", - "content_type": [ - "accounts", - "user" - ], - "codename": "view_user" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add post", - "content_type": [ - "core", - "post" - ], - "codename": "add_post" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change post", - "content_type": [ - "core", - "post" - ], - "codename": "change_post" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete post", - "content_type": [ - "core", - "post" - ], - "codename": "delete_post" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view post", - "content_type": [ - "core", - "post" - ], - "codename": "view_post" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add Category", - "content_type": [ - "core", - "category" - ], - "codename": "add_category" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change Category", - "content_type": [ - "core", - "category" - ], - "codename": "change_category" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete Category", - "content_type": [ - "core", - "category" - ], - "codename": "delete_category" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view Category", - "content_type": [ - "core", - "category" - ], - "codename": "view_category" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add collection rule", - "content_type": [ - "collection", - "collectionrule" - ], - "codename": "add_collectionrule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change collection rule", - "content_type": [ - "collection", - "collectionrule" - ], - "codename": "change_collectionrule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete collection rule", - "content_type": [ - "collection", - "collectionrule" - ], - "codename": "delete_collectionrule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view collection rule", - "content_type": [ - "collection", - "collectionrule" - ], - "codename": "view_collectionrule" - } -}, -{ - "model": "accounts.user", - "fields": { - "password": "pbkdf2_sha256$180000$U9a2CS9X0b8Y$T6bD/VoUOFoGNIp16aFlOL0N7q0e6A3I97ypm/AhsGo=", - "last_login": "2020-07-21T20:14:35.966Z", - "is_superuser": true, - "first_name": "", - "last_name": "", - "is_staff": true, - "is_active": true, - "date_joined": "2019-07-18T18:52:36.080Z", - "email": "sonny@bakker.nl", - "task": 10, - "reddit_refresh_token": null, - "reddit_access_token": null, - "groups": [], - "user_permissions": [] - } -}, -{ - "model": "core.category", - "pk": 8, - "fields": { - "created": "2019-11-17T19:37:24.671Z", - "modified": "2019-11-18T19:59:55.010Z", - "name": "World news", - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "core.category", - "pk": 9, - "fields": { - "created": "2019-11-17T19:37:26.161Z", - "modified": "2020-05-30T13:36:10.509Z", - "name": "Tech", - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 3, - "fields": { - "created": "2019-07-14T13:08:10.374Z", - "modified": "2020-07-14T11:45:30.680Z", - "name": "Hackers News", - "type": "feed", - "url": "https://news.ycombinator.com/rss", - "website_url": "https://news.ycombinator.com/", - "favicon": "https://news.ycombinator.com/favicon.ico", - "timezone": "UTC", - "category": 9, - "last_suceeded": "2020-07-14T11:45:30.477Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 4, - "fields": { - "created": "2019-07-20T11:24:32.745Z", - "modified": "2020-07-14T11:45:29.357Z", - "name": "BBC", - "type": "feed", - "url": "http://feeds.bbci.co.uk/news/world/rss.xml", - "website_url": "https://www.bbc.co.uk/news/", - "favicon": "https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png", - "timezone": "UTC", - "category": 8, - "last_suceeded": "2020-07-14T11:45:28.863Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 5, - "fields": { - "created": "2019-07-20T11:24:50.411Z", - "modified": "2020-07-14T11:45:30.063Z", - "name": "Ars Technica", - "type": "feed", - "url": "http://feeds.arstechnica.com/arstechnica/index?fmt=xml", - "website_url": "https://arstechnica.com", - "favicon": "https://cdn.arstechnica.net/favicon.ico", - "timezone": "UTC", - "category": 9, - "last_suceeded": "2020-07-14T11:45:29.810Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 6, - "fields": { - "created": "2019-07-20T11:25:02.089Z", - "modified": "2020-07-14T11:45:30.473Z", - "name": "The Guardian", - "type": "feed", - "url": "https://www.theguardian.com/world/rss", - "website_url": "https://www.theguardian.com/world", - "favicon": "https://assets.guim.co.uk/images/favicons/873381bf11d58e20f551905d51575117/72x72.png", - "timezone": "UTC", - "category": 8, - "last_suceeded": "2020-07-14T11:45:30.181Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 7, - "fields": { - "created": "2019-07-20T11:25:30.121Z", - "modified": "2020-07-14T11:45:29.807Z", - "name": "Tweakers", - "type": "feed", - "url": "http://feeds.feedburner.com/tweakers/mixed?fmt=xml", - "website_url": "https://tweakers.net/", - "favicon": null, - "timezone": "UTC", - "category": 9, - "last_suceeded": "2020-07-14T11:45:29.525Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 8, - "fields": { - "created": "2019-07-20T11:25:46.256Z", - "modified": "2020-07-14T11:45:30.179Z", - "name": "The Verge", - "type": "feed", - "url": "https://www.theverge.com/rss/index.xml", - "website_url": "https://www.theverge.com/", - "favicon": "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395367/favicon-16x16.0.png", - "timezone": "UTC", - "category": 9, - "last_suceeded": "2020-07-14T11:45:30.066Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 9, - "fields": { - "created": "2019-11-24T15:28:41.399Z", - "modified": "2020-07-14T11:45:29.522Z", - "name": "NOS", - "type": "feed", - "url": "http://feeds.nos.nl/nosnieuwsalgemeen", - "website_url": null, - "favicon": null, - "timezone": "Europe/Amsterdam", - "category": 8, - "last_suceeded": "2020-07-14T11:45:29.362Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 80, - "fields": { - "created": "2020-07-08T19:30:10.638Z", - "modified": "2020-07-21T20:14:50.609Z", - "name": "Linux subreddit", - "type": "subreddit", - "url": "https://oauth.reddit.com/r/linux/hot", - "website_url": null, - "favicon": null, - "timezone": "UTC", - "category": 9, - "last_suceeded": "2020-07-21T20:14:50.492Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 81, - "fields": { - "created": "2020-07-08T19:30:33.590Z", - "modified": "2020-07-21T20:14:50.865Z", - "name": "AWW subreddit", - "type": "subreddit", - "url": "https://oauth.reddit.com/r/aww/hot", - "website_url": null, - "favicon": null, - "timezone": "UTC", - "category": 8, - "last_suceeded": "2020-07-21T20:14:50.768Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 82, - "fields": { - "created": "2020-07-20T19:29:37.675Z", - "modified": "2020-07-21T20:14:50.489Z", - "name": "Star citizen subreddit", - "type": "subreddit", - "url": "https://oauth.reddit.com/r/starcitizen/hot.json", - "website_url": null, - "favicon": null, - "timezone": "UTC", - "category": 9, - "last_suceeded": "2020-07-21T20:14:50.355Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "admin.logentry", - "pk": 1, - "fields": { - "action_time": "2020-05-24T18:38:44.624Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "django_celery_beat", - "intervalschedule" - ], - "object_id": "5", - "object_repr": "every 4 hours", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 2, - "fields": { - "action_time": "2020-05-24T18:38:46.689Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "object_id": "10", - "object_repr": "sonny@bakker.nl-collection-task: every 4 hours", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Interval Schedule\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 3, - "fields": { - "action_time": "2020-05-24T18:39:09.203Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "object_id": "26", - "object_repr": "sonnyba871@gmail.com-collection-task: every hour", - "action_flag": 3, - "change_message": "" - } -}, -{ - "model": "admin.logentry", - "pk": 4, - "fields": { - "action_time": "2020-05-24T19:46:50.248Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "object_id": "10", - "object_repr": "sonny@bakker.nl-collection-task: every 4 hours", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Positional Arguments\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 5, - "fields": { - "action_time": "2020-07-07T19:37:57.086Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "accounts", - "user" - ], - "object_id": "1", - "object_repr": "sonny@bakker.nl", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Reddit refresh token\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 6, - "fields": { - "action_time": "2020-07-07T19:39:46.160Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "object_id": "10", - "object_repr": "sonny@bakker.nl-collection-task: every 4 hours", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Task (registered)\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 7, - "fields": { - "action_time": "2020-07-08T19:29:27.025Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "object_id": "11", - "object_repr": "Reddit collection task: every 4 hours", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 8, - "fields": { - "action_time": "2020-07-14T11:46:50.039Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "accounts", - "user" - ], - "object_id": "1", - "object_repr": "sonny@bakker.nl", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Reddit access token\", \"Reddit refresh token\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 9, - "fields": { - "action_time": "2020-07-18T19:08:33.997Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "collection", - "collectionrule" - ], - "object_id": "81", - "object_repr": "AWW subreddit", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Url\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 10, - "fields": { - "action_time": "2020-07-18T19:08:44.063Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "collection", - "collectionrule" - ], - "object_id": "80", - "object_repr": "Linux subreddit", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Url\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 11, - "fields": { - "action_time": "2020-07-18T19:17:25.213Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "core", - "post" - ], - "object_id": "2336", - "object_repr": "Post-2336", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 12, - "fields": { - "action_time": "2020-07-18T19:17:40.596Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "core", - "post" - ], - "object_id": "2336", - "object_repr": "Post-2336", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 13, - "fields": { - "action_time": "2020-07-19T10:55:55.807Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "core", - "post" - ], - "object_id": "2764", - "object_repr": "Post-2764", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 14, - "fields": { - "action_time": "2020-07-19T10:57:40.643Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "core", - "post" - ], - "object_id": "2764", - "object_repr": "Post-2764", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 15, - "fields": { - "action_time": "2020-07-19T10:58:05.823Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "core", - "post" - ], - "object_id": "2764", - "object_repr": "Post-2764", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 16, - "fields": { - "action_time": "2020-07-26T09:51:52.478Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "accounts", - "user" - ], - "object_id": "1", - "object_repr": "sonny@bakker.nl", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"First name\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 17, - "fields": { - "action_time": "2020-07-26T09:52:04.691Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "accounts", - "user" - ], - "object_id": "1", - "object_repr": "sonny@bakker.nl", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"password\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 18, - "fields": { - "action_time": "2020-07-26T09:52:12.392Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "accounts", - "user" - ], - "object_id": "1", - "object_repr": "sonny@bakker.nl", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"First name\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 19, - "fields": { - "action_time": "2020-07-26T09:56:15.949Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "accounts", - "user" - ], - "object_id": "1", - "object_repr": "sonny@bakker.nl", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Reddit access token\", \"Reddit refresh token\"]}}]" - } -} -] +[ +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "admin", + "model": "logentry" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "auth", + "model": "permission" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "auth", + "model": "group" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "contenttypes", + "model": "contenttype" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "sessions", + "model": "session" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "crontabschedule" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "intervalschedule" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "periodictask" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "periodictasks" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "solarschedule" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "clockedschedule" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "registration", + "model": "registrationprofile" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "registration", + "model": "supervisedregistrationprofile" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "axes", + "model": "accessattempt" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "axes", + "model": "accesslog" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "accounts", + "model": "user" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "core", + "model": "post" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "core", + "model": "category" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "collection", + "model": "collectionrule" + } +}, +{ + "model": "sessions.session", + "pk": "3sumq22krk8tsvexcs4b8czu82yhvuer", + "fields": { + "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", + "expire_date": "2020-05-16T18:29:04.049Z" + } +}, +{ + "model": "sessions.session", + "pk": "8ix6bdwf2ywk0eir1hb062dhfh9xit85", + "fields": { + "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", + "expire_date": "2020-07-21T19:36:54.530Z" + } +}, +{ + "model": "sessions.session", + "pk": "d4wophwpjm8z96doe8iddvhdv9yfafyx", + "fields": { + "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", + "expire_date": "2020-06-07T19:45:49.727Z" + } +}, +{ + "model": "sessions.session", + "pk": "g23ziz66li5zx8nd8cewb3vevdxhjkm0", + "fields": { + "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", + "expire_date": "2020-06-30T06:55:50.747Z" + } +}, +{ + "model": "sessions.session", + "pk": "jwn66dptmdkm6hom2ns3j288aaxqtyjd", + "fields": { + "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", + "expire_date": "2020-06-07T18:38:19.116Z" + } +}, +{ + "model": "sessions.session", + "pk": "wjz6kwg5e5ciemre0l0wwyrcwcj2gyg6", + "fields": { + "session_data": "MWU5ODBjY2QyOTFhMmRiY2QyYjQwZjQ3MmMwYmExYjBlYTkxNTcwODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiI0YWZkYTkxNzU5ZDBhZDZmMjg1ZTQyOGY0OTUxN2M5MTJhMmM5NWIyIn0=", + "expire_date": "2020-08-09T09:52:04.705Z" + } +}, +{ + "model": "django_celery_beat.intervalschedule", + "pk": 1, + "fields": { + "every": 5, + "period": "minutes" + } +}, +{ + "model": "django_celery_beat.intervalschedule", + "pk": 2, + "fields": { + "every": 15, + "period": "minutes" + } +}, +{ + "model": "django_celery_beat.intervalschedule", + "pk": 3, + "fields": { + "every": 30, + "period": "minutes" + } +}, +{ + "model": "django_celery_beat.intervalschedule", + "pk": 4, + "fields": { + "every": 1, + "period": "hours" + } +}, +{ + "model": "django_celery_beat.intervalschedule", + "pk": 5, + "fields": { + "every": 4, + "period": "hours" + } +}, +{ + "model": "django_celery_beat.crontabschedule", + "pk": 1, + "fields": { + "minute": "0", + "hour": "4", + "day_of_week": "*", + "day_of_month": "*", + "month_of_year": "*", + "timezone": "UTC" + } +}, +{ + "model": "django_celery_beat.periodictasks", + "pk": 1, + "fields": { + "last_update": "2020-07-26T09:47:48.298Z" + } +}, +{ + "model": "django_celery_beat.periodictask", + "pk": 1, + "fields": { + "name": "celery.backend_cleanup", + "task": "celery.backend_cleanup", + "interval": null, + "crontab": 1, + "solar": null, + "clocked": null, + "args": "[]", + "kwargs": "{}", + "queue": null, + "exchange": null, + "routing_key": null, + "headers": "{}", + "priority": null, + "expires": null, + "expire_seconds": 43200, + "one_off": false, + "start_time": null, + "enabled": true, + "last_run_at": "2020-07-26T09:47:48.322Z", + "total_run_count": 17, + "date_changed": "2020-07-26T09:47:50.362Z", + "description": "" + } +}, +{ + "model": "django_celery_beat.periodictask", + "pk": 10, + "fields": { + "name": "sonny@bakker.nl-collection-task", + "task": "FeedTask", + "interval": 5, + "crontab": null, + "solar": null, + "clocked": null, + "args": "[1]", + "kwargs": "{}", + "queue": null, + "exchange": null, + "routing_key": null, + "headers": "{}", + "priority": null, + "expires": null, + "expire_seconds": null, + "one_off": false, + "start_time": null, + "enabled": false, + "last_run_at": "2020-07-14T11:45:26.209Z", + "total_run_count": 307, + "date_changed": "2020-07-14T11:45:41.282Z", + "description": "" + } +}, +{ + "model": "django_celery_beat.periodictask", + "pk": 11, + "fields": { + "name": "Reddit collection task", + "task": "RedditTask", + "interval": 5, + "crontab": null, + "solar": null, + "clocked": null, + "args": "[]", + "kwargs": "{}", + "queue": null, + "exchange": null, + "routing_key": null, + "headers": "{}", + "priority": null, + "expires": null, + "expire_seconds": null, + "one_off": false, + "start_time": null, + "enabled": false, + "last_run_at": null, + "total_run_count": 4, + "date_changed": "2020-07-14T11:45:41.316Z", + "description": "" + } +}, +{ + "model": "core.post", + "pk": 3061, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:14:50.423Z", + "title": "Star Citizen: Question and Answer Thread", + "body": "

      Welcome to the Star Citizen question and answer thread. Feel free to ask any questions you have related to SC here!

      \n\n\n\n

      Useful Links and Resources:

      \n\n

      Star Citizen Wiki - The biggest and best wiki resource dedicated to Star Citizen

      \n\n

      Star Citizen FAQ - Chances the answer you need is here.

      \n\n

      Discord Help Channel - Often times community members will be here to help you with issues.

      \n\n

      Referral Code Randomizer - Use this when creating a new account to get 5000 extra UEC.

      \n\n

      Download Star Citizen - Get the latest version of Star Citizen here

      \n\n

      Current Game Features - Click here to see what you can currently do in Star Citizen.

      \n\n

      Development Roadmap - The current development status of up and coming Star Citizen features.

      \n\n

      Pledge FAQ - Official FAQ regarding spending money on the game.

      \n
      ", + "author": "UEE_Central_Computer", + "publication_date": "2020-07-20T14:00:10Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huk04t/star_citizen_question_and_answer_thread/", + "read": false, + "rule": 82, + "remote_identifier": "huk04t" + } +}, +{ + "model": "core.post", + "pk": 3062, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:33:37.019Z", + "title": "Peace and Quiet", + "body": "
      \"Peace
      ", + "author": "SourMemeNZ", + "publication_date": "2020-07-20T14:09:49Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huk4ib/peace_and_quiet/", + "read": true, + "rule": 82, + "remote_identifier": "huk4ib" + } +}, +{ + "model": "core.post", + "pk": 3063, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:14:50.463Z", + "title": "Y'all are probably sick of em by now but here's my LEGO Mercury Star Runner (MSR).", + "body": "
      \"Y'all
      ", + "author": "osamadabinman", + "publication_date": "2020-07-20T19:53:23Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hupzqa/yall_are_probably_sick_of_em_by_now_but_heres_my/", + "read": true, + "rule": 82, + "remote_identifier": "hupzqa" + } +}, +{ + "model": "core.post", + "pk": 3064, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:17:12.253Z", + "title": "Damned Space Invaders and their pixel weapons!", + "body": "
      \"Damned
      ", + "author": "Akaradrin", + "publication_date": "2020-07-20T14:26:18Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hukckf/damned_space_invaders_and_their_pixel_weapons/", + "read": true, + "rule": 82, + "remote_identifier": "hukckf" + } +}, +{ + "model": "core.post", + "pk": 3065, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:32:35.578Z", + "title": "The sky is no longer the limit", + "body": "
      \"The
      ", + "author": "CyberTill", + "publication_date": "2020-07-20T14:11:31Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huk5b8/the_sky_is_no_longer_the_limit/", + "read": false, + "rule": 82, + "remote_identifier": "huk5b8" + } +}, +{ + "model": "core.post", + "pk": 3066, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:17:23.282Z", + "title": "Terrapin Hover Mode Gameplay [Full Video in Comments]", + "body": "
      ", + "author": "Didactic_Tomato", + "publication_date": "2020-07-20T11:01:13Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hui1gv/terrapin_hover_mode_gameplay_full_video_in/", + "read": true, + "rule": 82, + "remote_identifier": "hui1gv" + } +}, +{ + "model": "core.post", + "pk": 3067, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:17:44.250Z", + "title": "honestly", + "body": "
      \"honestly\"
      ", + "author": "Beatlead", + "publication_date": "2020-07-20T18:24:07Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huo96t/honestly/", + "read": true, + "rule": 82, + "remote_identifier": "huo96t" + } +}, +{ + "model": "core.post", + "pk": 3068, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:32:35.584Z", + "title": "As a paranoiac and tired of checking if door was closed, saved to f4 thoses \"security cam\" positions, could be usefull for larger ships :)", + "body": "", + "author": "icwiener__", + "publication_date": "2020-07-20T13:03:33Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hujchz/as_a_paranoiac_and_tired_of_checking_if_door_was/", + "read": false, + "rule": 82, + "remote_identifier": "hujchz" + } +}, +{ + "model": "core.post", + "pk": 3069, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:33:59.158Z", + "title": "Station Manager: \"You're too fat, we won't let you in, go and fall on Lorville. Thank you for your call!\" Me: \"okay :'(\"", + "body": "
      \"Station
      ", + "author": "Shaman_N_One", + "publication_date": "2020-07-20T11:33:38Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huidlu/station_manager_youre_too_fat_we_wont_let_you_in/", + "read": true, + "rule": 82, + "remote_identifier": "huidlu" + } +}, +{ + "model": "core.post", + "pk": 3070, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:32:35.588Z", + "title": "[PTU Bug Hunt Request] Packet Loss", + "body": "", + "author": "Rainwalker007", + "publication_date": "2020-07-20T18:38:03Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huoicq/ptu_bug_hunt_request_packet_loss/", + "read": false, + "rule": 82, + "remote_identifier": "huoicq" + } +}, +{ + "model": "core.post", + "pk": 3071, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:17:52.092Z", + "title": "Anyone able to explain these \"trail frames\"?", + "body": "
      \"Anyone
      ", + "author": "Abnormal_Sloth", + "publication_date": "2020-07-20T17:11:32Z", + "url": "https://www.reddit.com/r/starcitizen/comments/humyeq/anyone_able_to_explain_these_trail_frames/", + "read": true, + "rule": 82, + "remote_identifier": "humyeq" + } +}, +{ + "model": "core.post", + "pk": 3072, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:32:35.593Z", + "title": "#BringBackBugSmasher - A long forgotten legendary video content", + "body": "", + "author": "MasterBoring", + "publication_date": "2020-07-20T18:05:54Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hunx77/bringbackbugsmasher_a_long_forgotten_legendary/", + "read": false, + "rule": 82, + "remote_identifier": "hunx77" + } +}, +{ + "model": "core.post", + "pk": 3073, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:33:22.601Z", + "title": "Oracle Helmet [in-game screenshot; downsampled to 4k]", + "body": "
      \"Oracle
      ", + "author": "mr-hasgaha", + "publication_date": "2020-07-20T17:39:34Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hung0b/oracle_helmet_ingame_screenshot_downsampled_to_4k/", + "read": true, + "rule": 82, + "remote_identifier": "hung0b" + } +}, +{ + "model": "core.post", + "pk": 3074, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:34:42.578Z", + "title": "Testing 3.10 - Gladius in decoupled mode", + "body": "
      ", + "author": "DarkConstant", + "publication_date": "2020-07-19T21:26:52Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hu6f1h/testing_310_gladius_in_decoupled_mode/", + "read": true, + "rule": 82, + "remote_identifier": "hu6f1h" + } +}, +{ + "model": "core.post", + "pk": 3075, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:34:29.424Z", + "title": "Day 3, I can't stop taking pictures with my Carrack. Send help", + "body": "
      \"Day
      ", + "author": "CyberTill", + "publication_date": "2020-07-20T01:58:15Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huazyy/day_3_i_cant_stop_taking_pictures_with_my_carrack/", + "read": true, + "rule": 82, + "remote_identifier": "huazyy" + } +}, +{ + "model": "core.post", + "pk": 3076, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:32:35.602Z", + "title": "I used to enjoy flying between the buildings of new babbage, I mean before the NFZ \"improvement\"", + "body": "
      \"I
      ", + "author": "shoeii", + "publication_date": "2020-07-20T16:40:26Z", + "url": "https://www.reddit.com/r/starcitizen/comments/humet2/i_used_to_enjoy_flying_between_the_buildings_of/", + "read": false, + "rule": 82, + "remote_identifier": "humet2" + } +}, +{ + "model": "core.post", + "pk": 3077, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:18:04.237Z", + "title": "Thank you CIG for updated heightmaps and render distances", + "body": "
      \"Thank
      ", + "author": "u7f76", + "publication_date": "2020-07-19T23:38:22Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hu8pwf/thank_you_cig_for_updated_heightmaps_and_render/", + "read": true, + "rule": 82, + "remote_identifier": "hu8pwf" + } +}, +{ + "model": "core.post", + "pk": 3078, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:32:35.607Z", + "title": "This Week in Star Citizen | July 20th 2020", + "body": "", + "author": "ivtiprogamer", + "publication_date": "2020-07-20T19:50:29Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hupxnt/this_week_in_star_citizen_july_20th_2020/", + "read": false, + "rule": 82, + "remote_identifier": "hupxnt" + } +}, +{ + "model": "core.post", + "pk": 3079, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:34:36.068Z", + "title": "Bravo CIG lighting team! Noticeable improvements to all around environment lighting in 3.10", + "body": "
      \"Bravo
      ", + "author": "u7f76", + "publication_date": "2020-07-20T00:02:23Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hu94o0/bravo_cig_lighting_team_noticeable_improvements/", + "read": true, + "rule": 82, + "remote_identifier": "hu94o0" + } +}, +{ + "model": "core.post", + "pk": 3080, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:32:35.613Z", + "title": "Thick", + "body": "
      \"Thick\"
      ", + "author": "burgerbagel", + "publication_date": "2020-07-20T16:24:38Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hum50f/thick/", + "read": false, + "rule": 82, + "remote_identifier": "hum50f" + } +}, +{ + "model": "core.post", + "pk": 3081, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:34:19.763Z", + "title": "Soon\u2122", + "body": "
      \"Soon\u2122\"
      ", + "author": "Mistralette", + "publication_date": "2020-07-20T05:54:09Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hueg01/soon/", + "read": true, + "rule": 82, + "remote_identifier": "hueg01" + } +}, +{ + "model": "core.post", + "pk": 3082, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:32:35.618Z", + "title": "On the prowl", + "body": "
      \"On
      ", + "author": "SaraCaterina", + "publication_date": "2020-07-20T16:37:03Z", + "url": "https://www.reddit.com/r/starcitizen/comments/humcmb/on_the_prowl/", + "read": false, + "rule": 82, + "remote_identifier": "humcmb" + } +}, +{ + "model": "core.post", + "pk": 3083, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:34:07.272Z", + "title": "The Hills Have Eyes", + "body": "
      \"The
      ", + "author": "FallenLordik", + "publication_date": "2020-07-20T11:19:19Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hui8ao/the_hills_have_eyes/", + "read": true, + "rule": 82, + "remote_identifier": "hui8ao" + } +}, +{ + "model": "core.post", + "pk": 3084, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:32:35.623Z", + "title": "Worried about longer loading screens? Hit ~ and do r_displayinfo 3", + "body": "
      \"Worried
      ", + "author": "kristokn", + "publication_date": "2020-07-20T10:09:53Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huhif1/worried_about_longer_loading_screens_hit_and_do_r/", + "read": false, + "rule": 82, + "remote_identifier": "huhif1" + } +}, +{ + "model": "core.post", + "pk": 3085, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:32:35.625Z", + "title": "My contribution to the wallpaper contest... click for the full effect (3440x1440)", + "body": "
      \"My
      ", + "author": "Dougie_Juice", + "publication_date": "2020-07-20T20:02:31Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huq655/my_contribution_to_the_wallpaper_contest_click/", + "read": false, + "rule": 82, + "remote_identifier": "huq655" + } +}, +{ + "model": "core.post", + "pk": 3086, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:32:35.627Z", + "title": "Star Citizen: The Onion (Parody Project)", + "body": "", + "author": "BroadOne", + "publication_date": "2020-07-20T19:19:20Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hupbkj/star_citizen_the_onion_parody_project/", + "read": false, + "rule": 82, + "remote_identifier": "hupbkj" + } +}, +{ + "model": "core.post", + "pk": 3087, + "fields": { + "created": "2020-07-20T19:32:35.635Z", + "modified": "2020-07-20T19:32:35.637Z", + "title": "perfect day to sunbathe", + "body": "
      ", + "author": "Pedrica1", + "publication_date": "2020-07-20T18:08:17Z", + "url": "https://www.reddit.com/r/aww/comments/hunysb/perfect_day_to_sunbathe/", + "read": false, + "rule": 81, + "remote_identifier": "hunysb" + } +}, +{ + "model": "core.post", + "pk": 3088, + "fields": { + "created": "2020-07-20T19:32:35.635Z", + "modified": "2020-07-20T19:32:35.639Z", + "title": "My dogs face when he sees I'm home", + "body": "
      ", + "author": "NewReddit_WhoDis", + "publication_date": "2020-07-20T16:45:21Z", + "url": "https://www.reddit.com/r/aww/comments/humhxa/my_dogs_face_when_he_sees_im_home/", + "read": false, + "rule": 81, + "remote_identifier": "humhxa" + } +}, +{ + "model": "core.post", + "pk": 3089, + "fields": { + "created": "2020-07-20T19:32:35.635Z", + "modified": "2020-07-20T19:32:35.641Z", + "title": "Cow loves the scritch machine", + "body": "
      ", + "author": "Der_Ist", + "publication_date": "2020-07-20T17:36:16Z", + "url": "https://www.reddit.com/r/aww/comments/hundvo/cow_loves_the_scritch_machine/", + "read": false, + "rule": 81, + "remote_identifier": "hundvo" + } +}, +{ + "model": "core.post", + "pk": 3090, + "fields": { + "created": "2020-07-20T19:32:35.635Z", + "modified": "2020-07-20T19:32:35.643Z", + "title": "Can I sit next to you ?", + "body": "
      ", + "author": "wheezy098", + "publication_date": "2020-07-20T17:55:10Z", + "url": "https://www.reddit.com/r/aww/comments/hunq5h/can_i_sit_next_to_you/", + "read": false, + "rule": 81, + "remote_identifier": "hunq5h" + } +}, +{ + "model": "core.post", + "pk": 3091, + "fields": { + "created": "2020-07-20T19:32:35.635Z", + "modified": "2020-07-20T19:32:35.645Z", + "title": "IS THAT A CUSTOMER? flop flop flop flop .... \" Can I uhh... help you sir?\"", + "body": "
      ", + "author": "MBMV", + "publication_date": "2020-07-20T12:50:40Z", + "url": "https://www.reddit.com/r/aww/comments/huj7g3/is_that_a_customer_flop_flop_flop_flop_can_i_uhh/", + "read": false, + "rule": 81, + "remote_identifier": "huj7g3" + } +}, +{ + "model": "core.post", + "pk": 3092, + "fields": { + "created": "2020-07-20T19:32:35.635Z", + "modified": "2020-07-20T19:32:35.647Z", + "title": "Good Boy turned Disney Princess", + "body": "
      ", + "author": "Sauwercraud", + "publication_date": "2020-07-20T18:40:05Z", + "url": "https://www.reddit.com/r/aww/comments/huojq0/good_boy_turned_disney_princess/", + "read": false, + "rule": 81, + "remote_identifier": "huojq0" + } +}, +{ + "model": "core.post", + "pk": 3093, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.649Z", + "title": "Kitty loop", + "body": "
      ", + "author": "Dlatrex", + "publication_date": "2020-07-20T12:54:02Z", + "url": "https://www.reddit.com/r/aww/comments/huj8s6/kitty_loop/", + "read": false, + "rule": 81, + "remote_identifier": "huj8s6" + } +}, +{ + "model": "core.post", + "pk": 3094, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.652Z", + "title": "if i fits i sits", + "body": "
      ", + "author": "jasontaken", + "publication_date": "2020-07-20T16:38:32Z", + "url": "https://www.reddit.com/r/aww/comments/humdlf/if_i_fits_i_sits/", + "read": false, + "rule": 81, + "remote_identifier": "humdlf" + } +}, +{ + "model": "core.post", + "pk": 3095, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.654Z", + "title": "Isn\u2019t she Adorable !", + "body": "
      \"Isn\u2019t
      ", + "author": "MunchyMac", + "publication_date": "2020-07-20T16:18:05Z", + "url": "https://www.reddit.com/r/aww/comments/hum133/isnt_she_adorable/", + "read": false, + "rule": 81, + "remote_identifier": "hum133" + } +}, +{ + "model": "core.post", + "pk": 3096, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.655Z", + "title": "Thank you mama (\u2283\uff61\u2022\u0301\u203f\u2022\u0300\uff61)\u2283", + "body": "
      ", + "author": "AnoushkaSingh", + "publication_date": "2020-07-20T13:35:51Z", + "url": "https://www.reddit.com/r/aww/comments/hujpxy/thank_you_mama/", + "read": false, + "rule": 81, + "remote_identifier": "hujpxy" + } +}, +{ + "model": "core.post", + "pk": 3097, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.657Z", + "title": "I WANT TO HUG HIM SO BAD!!!", + "body": "
      ", + "author": "BATMAN_5777", + "publication_date": "2020-07-20T18:25:20Z", + "url": "https://www.reddit.com/r/aww/comments/huo9z4/i_want_to_hug_him_so_bad/", + "read": false, + "rule": 81, + "remote_identifier": "huo9z4" + } +}, +{ + "model": "core.post", + "pk": 3098, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.659Z", + "title": "Before and after being called a good boy", + "body": "
      \"Before
      ", + "author": "vladgrinch", + "publication_date": "2020-07-20T10:48:40Z", + "url": "https://www.reddit.com/r/aww/comments/huhwu9/before_and_after_being_called_a_good_boy/", + "read": false, + "rule": 81, + "remote_identifier": "huhwu9" + } +}, +{ + "model": "core.post", + "pk": 3099, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.662Z", + "title": "My fianc\u00e9 has wanted a dog his whole life. This is his college graduation present. Welcome home Maple!", + "body": "
      \"My
      ", + "author": "AlexisaurusRex", + "publication_date": "2020-07-20T17:57:25Z", + "url": "https://www.reddit.com/r/aww/comments/hunrie/my_fianc\u00e9_has_wanted_a_dog_his_whole_life_this_is/", + "read": false, + "rule": 81, + "remote_identifier": "hunrie" + } +}, +{ + "model": "core.post", + "pk": 3100, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.664Z", + "title": "Cute burro.", + "body": "
      \"Cute
      ", + "author": "Craftmine101", + "publication_date": "2020-07-20T13:45:32Z", + "url": "https://www.reddit.com/r/aww/comments/huju40/cute_burro/", + "read": false, + "rule": 81, + "remote_identifier": "huju40" + } +}, +{ + "model": "core.post", + "pk": 3101, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.666Z", + "title": "I've never seen anyone dance better than that turtle.", + "body": "
      ", + "author": "Ashley1023", + "publication_date": "2020-07-20T18:07:30Z", + "url": "https://www.reddit.com/r/aww/comments/hunya8/ive_never_seen_anyone_dance_better_than_that/", + "read": false, + "rule": 81, + "remote_identifier": "hunya8" + } +}, +{ + "model": "core.post", + "pk": 3102, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.669Z", + "title": "Someone\u2019s going to be quite surprised when he realizes all this new stuff isn\u2019t for him!", + "body": "
      \"Someone\u2019s
      ", + "author": "molly590", + "publication_date": "2020-07-20T15:46:21Z", + "url": "https://www.reddit.com/r/aww/comments/hulikg/someones_going_to_be_quite_surprised_when_he/", + "read": false, + "rule": 81, + "remote_identifier": "hulikg" + } +}, +{ + "model": "core.post", + "pk": 3103, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.671Z", + "title": "my aunt asked me to paint her puppy and I think it turned out so cute!!!", + "body": "
      \"my
      ", + "author": "PineappleLightt", + "publication_date": "2020-07-20T16:39:37Z", + "url": "https://www.reddit.com/r/aww/comments/humea0/my_aunt_asked_me_to_paint_her_puppy_and_i_think/", + "read": false, + "rule": 81, + "remote_identifier": "humea0" + } +}, +{ + "model": "core.post", + "pk": 3104, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.673Z", + "title": "Master Assassin", + "body": "
      \"Master
      ", + "author": "LauWalker", + "publication_date": "2020-07-20T18:47:52Z", + "url": "https://www.reddit.com/r/aww/comments/huop8a/master_assassin/", + "read": false, + "rule": 81, + "remote_identifier": "huop8a" + } +}, +{ + "model": "core.post", + "pk": 3105, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.675Z", + "title": "Every time this tank cleaner cleans out the aquarium, this fish swims over to him looking for pets", + "body": "", + "author": "unnaturalorder", + "publication_date": "2020-07-20T05:29:30Z", + "url": "https://www.reddit.com/r/aww/comments/hue3r0/every_time_this_tank_cleaner_cleans_out_the/", + "read": false, + "rule": 81, + "remote_identifier": "hue3r0" + } +}, +{ + "model": "core.post", + "pk": 3106, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.678Z", + "title": "My girlfriend sent me this while I was at work. And here I was thinking the perfect picture of our dog didn't exist", + "body": "", + "author": "Khuma-zi_Eldrama", + "publication_date": "2020-07-20T19:22:48Z", + "url": "https://www.reddit.com/r/aww/comments/hupdz8/my_girlfriend_sent_me_this_while_i_was_at_work/", + "read": false, + "rule": 81, + "remote_identifier": "hupdz8" + } +}, +{ + "model": "core.post", + "pk": 3107, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.680Z", + "title": "My first ever post, everyone meet my new baby girl Kiora! I\u2019m so in love with her\ud83e\udd7a\ud83d\udcab", + "body": "
      \"My
      ", + "author": "Dumpling2463", + "publication_date": "2020-07-20T05:34:29Z", + "url": "https://www.reddit.com/r/aww/comments/hue6dx/my_first_ever_post_everyone_meet_my_new_baby_girl/", + "read": false, + "rule": 81, + "remote_identifier": "hue6dx" + } +}, +{ + "model": "core.post", + "pk": 3108, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.682Z", + "title": "Dog splashing in water", + "body": "", + "author": "TheRikari", + "publication_date": "2020-07-20T15:44:02Z", + "url": "https://www.reddit.com/r/aww/comments/hulh8k/dog_splashing_in_water/", + "read": false, + "rule": 81, + "remote_identifier": "hulh8k" + } +}, +{ + "model": "core.post", + "pk": 3109, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.685Z", + "title": "They say taking breaks is the key to productivity!", + "body": "
      ", + "author": "Thereaper29", + "publication_date": "2020-07-20T05:43:40Z", + "url": "https://www.reddit.com/r/aww/comments/hueawt/they_say_taking_breaks_is_the_key_to_productivity/", + "read": false, + "rule": 81, + "remote_identifier": "hueawt" + } +}, +{ + "model": "core.post", + "pk": 3110, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.687Z", + "title": "I went away for 3 weeks, and now my cat is in love with my husband", + "body": "
      \"I
      ", + "author": "sillykittyish", + "publication_date": "2020-07-20T03:29:11Z", + "url": "https://www.reddit.com/r/aww/comments/hucd7u/i_went_away_for_3_weeks_and_now_my_cat_is_in_love/", + "read": false, + "rule": 81, + "remote_identifier": "hucd7u" + } +}, +{ + "model": "core.post", + "pk": 3111, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.689Z", + "title": "Can you feel the love", + "body": "
      ", + "author": "kettySewrdPic", + "publication_date": "2020-07-20T09:13:32Z", + "url": "https://www.reddit.com/r/aww/comments/hugx1k/can_you_feel_the_love/", + "read": false, + "rule": 81, + "remote_identifier": "hugx1k" + } +}, +{ + "model": "core.post", + "pk": 3112, + "fields": { + "created": "2020-07-20T19:32:35.835Z", + "modified": "2020-07-21T20:14:50.522Z", + "title": "Linux Experiences/Rants or Education/Certifications thread - July 20, 2020", + "body": "

      Welcome to r/linux rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.

      \n\n

      Let us know what's annoying you, whats making you happy, or something that you want to get out to r/linux but didn't make the cut into a full post of it's own.

      \n\n

      For those looking for certifications please use this megathread to ask about how to get certified whether it's for the business world or for your own satisfaction. Be sure to check out r/linuxadmin for more discussion in the SysAdmin world!

      \n\n

      Please keep questions in r/linuxquestions, r/linux4noobs, or the Wednesday automod thread.

      \n
      ", + "author": "AutoModerator", + "publication_date": "2020-07-20T06:12:00Z", + "url": "https://www.reddit.com/r/linux/comments/hueoo0/linux_experiencesrants_or_educationcertifications/", + "read": false, + "rule": 80, + "remote_identifier": "hueoo0" + } +}, +{ + "model": "core.post", + "pk": 3113, + "fields": { + "created": "2020-07-20T19:32:35.836Z", + "modified": "2020-07-21T20:19:49.339Z", + "title": "Unix Family Tree", + "body": "
      \"Unix
      ", + "author": "bauripalash", + "publication_date": "2020-07-20T10:32:15Z", + "url": "https://www.reddit.com/r/linux/comments/huhqrh/unix_family_tree/", + "read": true, + "rule": 80, + "remote_identifier": "huhqrh" + } +}, +{ + "model": "core.post", + "pk": 3114, + "fields": { + "created": "2020-07-20T19:32:35.836Z", + "modified": "2020-07-21T20:14:50.554Z", + "title": "NVIDIA open sourced part of NVAPI SDK to aid 'Windows emulation environments'", + "body": "", + "author": "ignapk", + "publication_date": "2020-07-20T13:17:19Z", + "url": "https://www.reddit.com/r/linux/comments/huji8c/nvidia_open_sourced_part_of_nvapi_sdk_to_aid/", + "read": false, + "rule": 80, + "remote_identifier": "huji8c" + } +}, +{ + "model": "core.post", + "pk": 3115, + "fields": { + "created": "2020-07-20T19:32:35.836Z", + "modified": "2020-07-21T20:14:50.551Z", + "title": "Jellyfin 10.6 released", + "body": "", + "author": "resoluti0n_", + "publication_date": "2020-07-20T16:40:05Z", + "url": "https://www.reddit.com/r/linux/comments/humekr/jellyfin_106_released/", + "read": false, + "rule": 80, + "remote_identifier": "humekr" + } +}, +{ + "model": "core.post", + "pk": 3116, + "fields": { + "created": "2020-07-20T19:32:35.836Z", + "modified": "2020-07-21T20:14:50.583Z", + "title": "[German] Article in major german newspaper about trying Linux and WSL. Literal: \"Why it's beneficial to try Linux now\"", + "body": "", + "author": "noname7890", + "publication_date": "2020-07-19T15:19:27Z", + "url": "https://www.reddit.com/r/linux/comments/hu0d5v/german_article_in_major_german_newspaper_about/", + "read": false, + "rule": 80, + "remote_identifier": "hu0d5v" + } +}, +{ + "model": "core.post", + "pk": 3117, + "fields": { + "created": "2020-07-20T19:32:35.837Z", + "modified": "2020-07-21T20:14:50.574Z", + "title": "Brian Kernighan: UNIX, C, AWK, AMPL, and Go Programming | AI Podcast #109 with Lex Fridman", + "body": "", + "author": "tinyatom", + "publication_date": "2020-07-20T08:48:35Z", + "url": "https://www.reddit.com/r/linux/comments/hugn0w/brian_kernighan_unix_c_awk_ampl_and_go/", + "read": false, + "rule": 80, + "remote_identifier": "hugn0w" + } +}, +{ + "model": "core.post", + "pk": 3118, + "fields": { + "created": "2020-07-20T19:32:35.837Z", + "modified": "2020-07-21T20:14:50.578Z", + "title": "Explaining Computers Host Christopher Barnatt Has Switched To Linux", + "body": "", + "author": "sysrpl", + "publication_date": "2020-07-20T13:00:02Z", + "url": "https://www.reddit.com/r/linux/comments/hujb12/explaining_computers_host_christopher_barnatt_has/", + "read": false, + "rule": 80, + "remote_identifier": "hujb12" + } +}, +{ + "model": "core.post", + "pk": 3119, + "fields": { + "created": "2020-07-20T19:32:35.837Z", + "modified": "2020-07-21T20:14:50.529Z", + "title": "Ireland donates contact tracing app to the Linux foundation.", + "body": "", + "author": "mathiasryan", + "publication_date": "2020-07-20T21:31:43Z", + "url": "https://www.reddit.com/r/linux/comments/hury4e/ireland_donates_contact_tracing_app_to_the_linux/", + "read": false, + "rule": 80, + "remote_identifier": "hury4e" + } +}, +{ + "model": "core.post", + "pk": 3120, + "fields": { + "created": "2020-07-20T19:32:35.842Z", + "modified": "2020-07-21T20:14:50.588Z", + "title": "I implemented a simple terminal-based password manager", + "body": "

      I created a simple, secure, and free password manager written in C: SaltPass. I haven't contributed open source code before, but I think this might be useful to a few people. Especially as an alternative to paid solutions such as LastPass and the likes. Any suggestions/edits/code improvements would be greatly appreciated!

      \n
      ", + "author": "zaid-gg", + "publication_date": "2020-07-20T07:43:03Z", + "url": "https://www.reddit.com/r/linux/comments/hufula/i_implemented_a_simple_terminalbased_password/", + "read": false, + "rule": 80, + "remote_identifier": "hufula" + } +}, +{ + "model": "core.post", + "pk": 3121, + "fields": { + "created": "2020-07-20T19:32:35.843Z", + "modified": "2020-07-21T20:14:50.593Z", + "title": "Performance analysis of multi services on container Docker, LXC, and LXD - Bulletin of Electrical Engineering and Informatics, Adinda Riztia Putri, Rendy Munadi, Ridha Muldina Negara Adaptive Network\u2026", + "body": "", + "author": "bmullan", + "publication_date": "2020-07-20T11:35:59Z", + "url": "https://www.reddit.com/r/linux/comments/huieio/performance_analysis_of_multi_services_on/", + "read": false, + "rule": 80, + "remote_identifier": "huieio" + } +}, +{ + "model": "core.post", + "pk": 3122, + "fields": { + "created": "2020-07-20T19:32:35.844Z", + "modified": "2020-07-21T20:14:50.602Z", + "title": "Create an Internal PKI using OpenSSL and NitroKey HSM", + "body": "", + "author": "PixelPaulaus", + "publication_date": "2020-07-20T06:18:41Z", + "url": "https://www.reddit.com/r/linux/comments/huerpn/create_an_internal_pki_using_openssl_and_nitrokey/", + "read": false, + "rule": 80, + "remote_identifier": "huerpn" + } +}, +{ + "model": "core.post", + "pk": 3123, + "fields": { + "created": "2020-07-20T19:32:35.844Z", + "modified": "2020-07-20T19:32:35.883Z", + "title": "vopono - run applications via VPNs with temporary network namespaces", + "body": "", + "author": "nivenkos", + "publication_date": "2020-07-19T20:02:57Z", + "url": "https://www.reddit.com/r/linux/comments/hu4vge/vopono_run_applications_via_vpns_with_temporary/", + "read": false, + "rule": 80, + "remote_identifier": "hu4vge" + } +}, +{ + "model": "core.post", + "pk": 3124, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.886Z", + "title": "Double (triple, quadruple...) internet speed with openvpn tap channel bonding to a linux VPS", + "body": "

      I have been working a couple of days on my latest video about channel bonding - the video is heavily inspired be this article on Serverfault. In essence, I have been searching for a while on how to bond multiple VPN channels together in order to increase internet speed - there does not seem to be a lot of information around - mainly articles on forums and reddit state that it should be possible but a detailed guide is hard to find. I am using two Ubuntu machines in order to build the connection - one local and one VPS. The bash scripts I use in my video in order to achieve tap channel bonding are available on my github repository. I am currently working on a second video in order to walk through and explain the scripts in depth. Enjoy!

      \n\n

      (EDIT) - the question has come up in the discussions below if this is really packet load balancing or rather balancing links only - please see my comment further down - I can confirm that this DOES packet balancing so it does work as described.

      \n
      ", + "author": "onemarcfifty", + "publication_date": "2020-07-19T20:41:40Z", + "url": "https://www.reddit.com/r/linux/comments/hu5l4f/double_triple_quadruple_internet_speed_with/", + "read": false, + "rule": 80, + "remote_identifier": "hu5l4f" + } +}, +{ + "model": "core.post", + "pk": 3125, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.888Z", + "title": "OpenRGB - Open source RGB lighting control that doesn't depend on manufacturer software, supports Linux", + "body": "", + "author": "pr0_c0d3", + "publication_date": "2020-07-18T16:52:48Z", + "url": "https://www.reddit.com/r/linux/comments/hthuli/openrgb_open_source_rgb_lighting_control_that/", + "read": false, + "rule": 80, + "remote_identifier": "hthuli" + } +}, +{ + "model": "core.post", + "pk": 3126, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.890Z", + "title": "Make this any sense? Automatic CPU Speed & Power Optimizer", + "body": "", + "author": "spite77", + "publication_date": "2020-07-20T11:53:35Z", + "url": "https://www.reddit.com/r/linux/comments/huikxz/make_this_any_sense_automatic_cpu_speed_power/", + "read": false, + "rule": 80, + "remote_identifier": "huikxz" + } +}, +{ + "model": "core.post", + "pk": 3127, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.891Z", + "title": "Let\u2019s not be pedantic about \u201cOpen Source\u201d", + "body": "", + "author": "speckz", + "publication_date": "2020-07-20T16:46:43Z", + "url": "https://www.reddit.com/r/linux/comments/humirw/lets_not_be_pedantic_about_open_source/", + "read": false, + "rule": 80, + "remote_identifier": "humirw" + } +}, +{ + "model": "core.post", + "pk": 3128, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.893Z", + "title": "Experiences with running Linux Lite", + "body": "", + "author": "daemonpenguin", + "publication_date": "2020-07-20T02:43:49Z", + "url": "https://www.reddit.com/r/linux/comments/hubonw/experiences_with_running_linux_lite/", + "read": false, + "rule": 80, + "remote_identifier": "hubonw" + } +}, +{ + "model": "core.post", + "pk": 3129, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.895Z", + "title": "Tried gnome on arch, surprised how lean it is (used flameshot so it used about 72mb more) closing at 600 megs) on fedora and pop i had gnome eating up 1.3gigs at boot up.", + "body": "
      \"Tried
      ", + "author": "V1n0dKr1shna", + "publication_date": "2020-07-18T13:54:55Z", + "url": "https://www.reddit.com/r/linux/comments/htfeph/tried_gnome_on_arch_surprised_how_lean_it_is_used/", + "read": false, + "rule": 80, + "remote_identifier": "htfeph" + } +}, +{ + "model": "core.post", + "pk": 3130, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.897Z", + "title": "The Free Software Foundation is holding a Fundraiser, help them reach 200 members", + "body": "", + "author": "Neet-Feet", + "publication_date": "2020-07-18T17:55:30Z", + "url": "https://www.reddit.com/r/linux/comments/htiuyi/the_free_software_foundation_is_holding_a/", + "read": false, + "rule": 80, + "remote_identifier": "htiuyi" + } +}, +{ + "model": "core.post", + "pk": 3131, + "fields": { + "created": "2020-07-20T19:32:35.853Z", + "modified": "2020-07-20T19:32:35.899Z", + "title": "Why is the mindset around Arch so negative?", + "body": "

      I love the Linux community as a whole. You can find some of the most creative and imaginative people within most Linux communities. On a whole, Linux users are some of the most helpful and informative people you can encounter. Truly the type to think outside the box and learn new things. It can be very inspirational.

      \n\n

      If I jumped onto Ubuntu, Fedora, or openSUSE's community I can have a free flowing conversation about Linux, their distribution, and getting help or giving help is so free-flowing and easy. The communities are eager to welcome new people and appreciate folks who contribute.

      \n\n

      Then you have Arch. I love the OS but dislike the mindset. Asking for help is meat with resistance, giving help can also be punishable, and god forbid you try to have a discussion. But it's not just their core community either. For example, I just discovered Endeavour OS which is built around Arch and after 11 post I'm told to come back in 8 hours. Their subReddit here on Reddit, you have to ask to even make 1 post. There of course is also Manjaro Linux and they too have this gatekeeper mindset, the same can be said for ArcoLinux.

      \n\n

      What is it about Arch that makes everyone want to be either a control freak or a gatekeeper?

      \n\n

      I do not see this within the Ubuntu or Fedora or openSUSE communities. As I said, their mindset seems eager and willing to unite and work as a community. Am I the only how has noticed this?

      \n
      ", + "author": "Linux-Is-Best", + "publication_date": "2020-07-18T23:28:12Z", + "url": "https://www.reddit.com/r/linux/comments/htojwk/why_is_the_mindset_around_arch_so_negative/", + "read": false, + "rule": 80, + "remote_identifier": "htojwk" + } +}, +{ + "model": "core.post", + "pk": 3132, + "fields": { + "created": "2020-07-20T19:32:35.853Z", + "modified": "2020-07-20T19:32:35.901Z", + "title": "Using the nstat network statistics command in Linux", + "body": "", + "author": "cronos426", + "publication_date": "2020-07-19T17:55:55Z", + "url": "https://www.reddit.com/r/linux/comments/hu2q6v/using_the_nstat_network_statistics_command_in/", + "read": false, + "rule": 80, + "remote_identifier": "hu2q6v" + } +}, +{ + "model": "core.post", + "pk": 3133, + "fields": { + "created": "2020-07-20T19:32:35.853Z", + "modified": "2020-07-20T19:32:35.903Z", + "title": "Contributing via GitLab Merge Requests", + "body": "", + "author": "ChristophCullmann", + "publication_date": "2020-07-18T20:01:26Z", + "url": "https://www.reddit.com/r/linux/comments/htl05p/contributing_via_gitlab_merge_requests/", + "read": false, + "rule": 80, + "remote_identifier": "htl05p" + } +}, +{ + "model": "core.post", + "pk": 3134, + "fields": { + "created": "2020-07-20T19:32:35.853Z", + "modified": "2020-07-20T19:32:35.905Z", + "title": "OpenMandriva: combines WINE64 and 32 into one package capable of running both binaries, i686 architecture was considered as deprecated. Work is underway on a new Rolling release", + "body": "", + "author": "DamonsLinux", + "publication_date": "2020-07-18T15:02:35Z", + "url": "https://www.reddit.com/r/linux/comments/htg9dj/openmandriva_combines_wine64_and_32_into_one/", + "read": false, + "rule": 80, + "remote_identifier": "htg9dj" + } +}, +{ + "model": "core.post", + "pk": 3135, + "fields": { + "created": "2020-07-20T19:32:35.853Z", + "modified": "2020-07-20T19:32:35.906Z", + "title": "OpenRCT2 Player Survey 2020 - Previous survey shows almost 25% players are linux, please help represent linux in the most recent survey", + "body": "", + "author": "christophski", + "publication_date": "2020-07-18T11:39:06Z", + "url": "https://www.reddit.com/r/linux/comments/htdzuh/openrct2_player_survey_2020_previous_survey_shows/", + "read": false, + "rule": 80, + "remote_identifier": "htdzuh" + } +}, +{ + "model": "core.post", + "pk": 3136, + "fields": { + "created": "2020-07-20T19:32:35.853Z", + "modified": "2020-07-20T19:32:35.908Z", + "title": "This week in KDE: Get New Stuff fixes and more", + "body": "", + "author": "kyentei", + "publication_date": "2020-07-18T10:03:46Z", + "url": "https://www.reddit.com/r/linux/comments/htd1an/this_week_in_kde_get_new_stuff_fixes_and_more/", + "read": false, + "rule": 80, + "remote_identifier": "htd1an" + } +}, +{ + "model": "core.post", + "pk": 3137, + "fields": { + "created": "2020-07-20T19:32:35.857Z", + "modified": "2020-07-20T19:32:35.910Z", + "title": "Blender Runs on Linux Pinephone", + "body": "

      I managed to get the desktop version of Blender on the Pinephone, and it works really well except for a few bugs.

      \n\n

      See my post on r/blender:

      \n\n

      https://www.reddit.com/r/blender/comments/hsxv27/i_installed_blender_on_a_phone/

      \n\n

      and r/PINE64official:

      \n\n

      https://www.reddit.com/r/PINE64official/comments/hsxc33/blender_on_pine_phone_almost_usable/

      \n\n

      I've tried other desktop programs like Xournal and PPSSPP, their UIs also work well, I'd be able to do even more if OpenGL 3 was working.

      \n
      ", + "author": "InfiniteHawk", + "publication_date": "2020-07-17T22:35:14Z", + "url": "https://www.reddit.com/r/linux/comments/ht3d4k/blender_runs_on_linux_pinephone/", + "read": false, + "rule": 80, + "remote_identifier": "ht3d4k" + } +}, +{ + "model": "core.post", + "pk": 3138, + "fields": { + "created": "2020-07-21T20:14:50.415Z", + "modified": "2020-07-21T20:18:21.616Z", + "title": "Hrmmm They Need to Fix Throttle Animations in the Sabre", + "body": "
      ", + "author": "TheBootRanger", + "publication_date": "2020-07-21T13:26:01Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv5omc/hrmmm_they_need_to_fix_throttle_animations_in_the/", + "read": true, + "rule": 82, + "remote_identifier": "hv5omc" + } +}, +{ + "model": "core.post", + "pk": 3139, + "fields": { + "created": "2020-07-21T20:14:50.415Z", + "modified": "2020-07-21T20:18:49.999Z", + "title": "My first 3.10 landing could have gone better...", + "body": "
      ", + "author": "KnLfey", + "publication_date": "2020-07-21T16:04:50Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv7w85/my_first_310_landing_could_have_gone_better/", + "read": true, + "rule": 82, + "remote_identifier": "hv7w85" + } +}, +{ + "model": "core.post", + "pk": 3140, + "fields": { + "created": "2020-07-21T20:14:50.415Z", + "modified": "2020-07-21T20:14:50.439Z", + "title": "How about the Christmas in 3 more years?", + "body": "
      \"How
      ", + "author": "SpleanEater", + "publication_date": "2020-07-21T17:49:22Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv9qy8/how_about_the_christmas_in_3_more_years/", + "read": false, + "rule": 82, + "remote_identifier": "hv9qy8" + } +}, +{ + "model": "core.post", + "pk": 3141, + "fields": { + "created": "2020-07-21T20:14:50.415Z", + "modified": "2020-07-21T20:18:33.532Z", + "title": "Long time Elite Dangerous player. New to star citizen i think im doing great", + "body": "", + "author": "Filblo5", + "publication_date": "2020-07-21T15:33:49Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv7elb/long_time_elite_dangerous_player_new_to_star/", + "read": true, + "rule": 82, + "remote_identifier": "hv7elb" + } +}, +{ + "model": "core.post", + "pk": 3142, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:14:50.443Z", + "title": "And we stand by it.", + "body": "
      \"And
      ", + "author": "CyberTill", + "publication_date": "2020-07-21T18:57:48Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvb3wm/and_we_stand_by_it/", + "read": false, + "rule": 82, + "remote_identifier": "hvb3wm" + } +}, +{ + "model": "core.post", + "pk": 3143, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:14:50.446Z", + "title": "Nomad", + "body": "
      \"Nomad\"
      ", + "author": "ibracitizen", + "publication_date": "2020-07-21T19:52:24Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvc5h3/nomad/", + "read": false, + "rule": 82, + "remote_identifier": "hvc5h3" + } +}, +{ + "model": "core.post", + "pk": 3144, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:14:50.449Z", + "title": "Probably the best screen cap i've ever caught on a whim. 3.5 Arc Corp release. Also a confession: I never pledged. Got a ship with my GPU. I intend to pay my dues.", + "body": "
      \"Probably
      ", + "author": "ScionoicS", + "publication_date": "2020-07-21T20:23:01Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvcqzf/probably_the_best_screen_cap_ive_ever_caught_on_a/", + "read": false, + "rule": 82, + "remote_identifier": "hvcqzf" + } +}, +{ + "model": "core.post", + "pk": 3145, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:14:50.451Z", + "title": "Play to escape the depressing job hunt where I need 10 years experience for a entry level job to find this, only been playing for 1 and a half years :(", + "body": "
      \"Play
      ", + "author": "Albert-III-", + "publication_date": "2020-07-21T12:23:45Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv4z08/play_to_escape_the_depressing_job_hunt_where_i/", + "read": false, + "rule": 82, + "remote_identifier": "hv4z08" + } +}, +{ + "model": "core.post", + "pk": 3146, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:19:00.691Z", + "title": "The void beckons.", + "body": "
      ", + "author": "HisNameWasHis", + "publication_date": "2020-07-21T14:40:51Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv6nij/the_void_beckons/", + "read": true, + "rule": 82, + "remote_identifier": "hv6nij" + } +}, +{ + "model": "core.post", + "pk": 3147, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:19:05.881Z", + "title": "I made a SC-like Photobash with Soldiers", + "body": "
      \"I
      ", + "author": "IsaacPolar", + "publication_date": "2020-07-21T17:13:39Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv92ri/i_made_a_sclike_photobash_with_soldiers/", + "read": true, + "rule": 82, + "remote_identifier": "hv92ri" + } +}, +{ + "model": "core.post", + "pk": 3148, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:19:41.227Z", + "title": "Ocean Shader Improvements", + "body": "
      \"Ocean
      ", + "author": "shoeii", + "publication_date": "2020-07-21T18:41:51Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvasds/ocean_shader_improvements/", + "read": true, + "rule": 82, + "remote_identifier": "hvasds" + } +}, +{ + "model": "core.post", + "pk": 3149, + "fields": { + "created": "2020-07-21T20:14:50.420Z", + "modified": "2020-07-21T20:14:50.459Z", + "title": "As much shit as Star Citizen (rightfully) gets it still does one thing better than any other 'game' I've played", + "body": "

      It invokes a real sense of scale, on multiple levels.

      \n\n

      One could argue that's one of the most important feelings you'd want to capture in any game set in space, but of course it's mostly meaningless if there aren't enough gameplay loops and systems in place to work in tandem with and make the space that's been created interesting, and that's where SC is currently a failure.

      \n\n

      Even so, I think being able to create that sense of smallness isn't insignificant.

      \n\n

      You as a pilot are dwarfed by your ship which is itself dwarfed by a larger ship which is itself dwarfed by another, even more massive one which is dwarfed by the space station or hub you're at which is dwarfed by a crater on a moon which is dwarfed by the moon itself which is dwarfed by the planet it orbits which is dwarfed by the sheer vastness of space in between all of those things and that they are, despite the distance, still connected.

      \n\n

      Getting lost in Lorville (even if it is mostly linear) and knowing it's only a small part of the playable space is a really neat feeling - looking out from the windows of the train up into the sky and knowing you can go there and beyond really makes you feel like there is a whole world (and more) waiting to be explored.

      \n\n

      I think this is a direct result of having legs and not being locked into the cockpit of your ship - I've played more Elite: Dangerous than Star Citizen and it accomplishes a similar sense of scale but, at least not as far as I've felt, never to the same degree - because you're locked in your ship you never really get this same sense of being small or insignificant even though you are dwarfed in similar ways by planets/asteroids/other ships - will be interesting to see how their implementation of 'space legs' in the upcoming expansion changes this.

      \n\n

      My favourite thing to do in Star Citizen (because there isn't a whole lot) is to just find some pocket of space far away from anything else and just walk around my ship, feeling truly alone and insignificant, gazing out at the void that stretches infinitely all around - something about that is super comfy.

      \n\n

      I can't think of many other game that accomplish a similar level of scale though I'm sure they exist.

      \n\n

      I've been playing an indie game called Empyrion - Galactic Survival and it actually is sort of similar to SC in this regard but it's nowhere near as polished or smooth - transitions from atmosphere to space are not truly seamless and planets themselves are kind of stitched together, but it still manages to invoke that same kind of awe at the scale of things when you dock a small vessel to a capital vessel, for example - definitely worth checking out if you like sci-fi/space games, which you must if you're here, but just be prepared for the jank.

      \n
      ", + "author": "thegreatself", + "publication_date": "2020-07-21T20:30:15Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvcw38/as_much_shit_as_star_citizen_rightfully_gets_it/", + "read": false, + "rule": 82, + "remote_identifier": "hvcw38" + } +}, +{ + "model": "core.post", + "pk": 3150, + "fields": { + "created": "2020-07-21T20:14:50.420Z", + "modified": "2020-07-21T20:14:50.462Z", + "title": "You waiting for patch 3.10 to go live while watching tons of videos about the new flight model features. Be patient, 3.11 and 3.12 will be even better.", + "body": "
      \"You
      ", + "author": "jsabater76", + "publication_date": "2020-07-21T09:39:27Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv372v/you_waiting_for_patch_310_to_go_live_while/", + "read": false, + "rule": 82, + "remote_identifier": "hv372v" + } +}, +{ + "model": "core.post", + "pk": 3151, + "fields": { + "created": "2020-07-21T20:14:50.420Z", + "modified": "2020-07-21T20:14:50.466Z", + "title": "CIG, can we please fix these \"black hole\" doors(when they are closed) on ships please.", + "body": "
      \"CIG,
      ", + "author": "AbnormallyBendPenis", + "publication_date": "2020-07-21T13:40:14Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv5uzj/cig_can_we_please_fix_these_black_hole_doorswhen/", + "read": false, + "rule": 82, + "remote_identifier": "hv5uzj" + } +}, +{ + "model": "core.post", + "pk": 3152, + "fields": { + "created": "2020-07-21T20:14:50.420Z", + "modified": "2020-07-21T20:14:50.468Z", + "title": "Anvil Super Hornet over Cellin", + "body": "
      \"Anvil
      ", + "author": "SaraCaterina", + "publication_date": "2020-07-21T20:33:58Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvcyq6/anvil_super_hornet_over_cellin/", + "read": false, + "rule": 82, + "remote_identifier": "hvcyq6" + } +}, +{ + "model": "core.post", + "pk": 3153, + "fields": { + "created": "2020-07-21T20:14:50.420Z", + "modified": "2020-07-21T20:14:50.471Z", + "title": "3.10 Combat Changes", + "body": "", + "author": "STLYoungblood", + "publication_date": "2020-07-21T16:37:44Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv8fr7/310_combat_changes/", + "read": false, + "rule": 82, + "remote_identifier": "hv8fr7" + } +}, +{ + "model": "core.post", + "pk": 3154, + "fields": { + "created": "2020-07-21T20:14:50.420Z", + "modified": "2020-07-21T20:14:50.472Z", + "title": "Hey CIG how about that S42 Vi.... Oh...", + "body": "
      \"Hey
      ", + "author": "SiEDeN", + "publication_date": "2020-07-21T21:37:16Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hve6am/hey_cig_how_about_that_s42_vi_oh/", + "read": false, + "rule": 82, + "remote_identifier": "hve6am" + } +}, +{ + "model": "core.post", + "pk": 3155, + "fields": { + "created": "2020-07-21T20:14:50.422Z", + "modified": "2020-07-21T20:14:50.475Z", + "title": "3.10 M PTU Eclipse improvements", + "body": "

      If this goes live, CIG had addressed 2 of my Eclipse critics.

      \n\n

      Not because of my videos of course, CIG doesn't know I exist.

      \n\n

       

      \n\n

      a. Eclipse has armor stealth in 3.10, see my table:\nhttps://docs.google.com/spreadsheets/d/1OJXg7MQsG_IVTPsmlmZYaxEPK4n4iqnhQx4oigIlJHg/edit#gid=343807746

      \n\n

       

      \n\n

      b. Eclipse can fire her size 9 torpedoes way quicker now, see my video with a side by side comparison of the max firing speed in 3.9 and 3.10:\nhttps://youtu.be/GFTF1Qt7T3o?t=207

      \n
      ", + "author": "Camural", + "publication_date": "2020-07-21T18:15:50Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hva9lc/310_m_ptu_eclipse_improvements/", + "read": false, + "rule": 82, + "remote_identifier": "hva9lc" + } +}, +{ + "model": "core.post", + "pk": 3156, + "fields": { + "created": "2020-07-21T20:14:50.422Z", + "modified": "2020-07-21T20:14:50.477Z", + "title": "Hark! The Drake Herald Sings", + "body": "
      \"Hark!
      ", + "author": "CyrexStorm", + "publication_date": "2020-07-21T16:19:31Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv84kk/hark_the_drake_herald_sings/", + "read": false, + "rule": 82, + "remote_identifier": "hv84kk" + } +}, +{ + "model": "core.post", + "pk": 3157, + "fields": { + "created": "2020-07-21T20:14:50.422Z", + "modified": "2020-07-21T20:14:50.479Z", + "title": "The new flight stick in the Prowler", + "body": "
      \"The
      ", + "author": "Potato_Nades", + "publication_date": "2020-07-21T16:22:22Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv86c2/the_new_flight_stick_in_the_prowler/", + "read": false, + "rule": 82, + "remote_identifier": "hv86c2" + } +}, +{ + "model": "core.post", + "pk": 3158, + "fields": { + "created": "2020-07-21T20:14:50.422Z", + "modified": "2020-07-21T20:14:50.481Z", + "title": "Norwegian VAT charged from August 1st", + "body": "
      \"Norwegian
      ", + "author": "norgeek", + "publication_date": "2020-07-21T10:30:57Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv3r3l/norwegian_vat_charged_from_august_1st/", + "read": false, + "rule": 82, + "remote_identifier": "hv3r3l" + } +}, +{ + "model": "core.post", + "pk": 3159, + "fields": { + "created": "2020-07-21T20:14:50.423Z", + "modified": "2020-07-21T20:14:50.484Z", + "title": "With Pyro (currently WIP), Nyx (partially done), Odin (S42), currently on the way, what is everyone\u2019s thoughts on Terra possibly being next on the list of star systems to be added into the PU within \u2026", + "body": "
      \"With
      ", + "author": "realCLTotaku", + "publication_date": "2020-07-21T13:27:09Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv5p41/with_pyro_currently_wip_nyx_partially_done_odin/", + "read": false, + "rule": 82, + "remote_identifier": "hv5p41" + } +}, +{ + "model": "core.post", + "pk": 3160, + "fields": { + "created": "2020-07-21T20:14:50.423Z", + "modified": "2020-07-21T20:14:50.486Z", + "title": "Testing out the new electron rifle", + "body": "
      ", + "author": "joshbaker2112", + "publication_date": "2020-07-21T02:56:19Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huxr6d/testing_out_the_new_electron_rifle/", + "read": false, + "rule": 82, + "remote_identifier": "huxr6d" + } +}, +{ + "model": "core.post", + "pk": 3161, + "fields": { + "created": "2020-07-21T20:14:50.423Z", + "modified": "2020-07-21T20:14:50.487Z", + "title": "Imperial Geographic's Lovecraftian magazine special is here. \ud83d\udc19 Find the link in the comments!", + "body": "
      \"Imperial
      ", + "author": "Good_Punk2", + "publication_date": "2020-07-21T18:21:38Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvadrh/imperial_geographics_lovecraftian_magazine/", + "read": false, + "rule": 82, + "remote_identifier": "hvadrh" + } +}, +{ + "model": "core.post", + "pk": 3162, + "fields": { + "created": "2020-07-21T20:14:50.497Z", + "modified": "2020-07-21T20:14:50.525Z", + "title": "Linux Distributions Timeline", + "body": "
      \"Linux
      ", + "author": "bauripalash", + "publication_date": "2020-07-21T06:07:59Z", + "url": "https://www.reddit.com/r/linux/comments/hv0ktn/linux_distributions_timeline/", + "read": false, + "rule": 80, + "remote_identifier": "hv0ktn" + } +}, +{ + "model": "core.post", + "pk": 3163, + "fields": { + "created": "2020-07-21T20:14:50.497Z", + "modified": "2020-07-21T20:14:50.527Z", + "title": "Fedora: Proposal to replace default wined3d backend with DXVK", + "body": "", + "author": "friskfrugt", + "publication_date": "2020-07-21T19:42:49Z", + "url": "https://www.reddit.com/r/linux/comments/hvbyyr/fedora_proposal_to_replace_default_wined3d/", + "read": false, + "rule": 80, + "remote_identifier": "hvbyyr" + } +}, +{ + "model": "core.post", + "pk": 3164, + "fields": { + "created": "2020-07-21T20:14:50.497Z", + "modified": "2020-07-21T20:14:50.531Z", + "title": "Update on marketing and communication plans for the LibreOffice 7.x series", + "body": "", + "author": "TheQuantumZero", + "publication_date": "2020-07-21T09:59:23Z", + "url": "https://www.reddit.com/r/linux/comments/hv3erm/update_on_marketing_and_communication_plans_for/", + "read": false, + "rule": 80, + "remote_identifier": "hv3erm" + } +}, +{ + "model": "core.post", + "pk": 3165, + "fields": { + "created": "2020-07-21T20:14:50.497Z", + "modified": "2020-07-21T20:14:50.533Z", + "title": "FOSS job opening: LibreOffice Development Mentor at The Document Foundation", + "body": "", + "author": "themikeosguy", + "publication_date": "2020-07-21T14:26:36Z", + "url": "https://www.reddit.com/r/linux/comments/hv6gfw/foss_job_opening_libreoffice_development_mentor/", + "read": false, + "rule": 80, + "remote_identifier": "hv6gfw" + } +}, +{ + "model": "core.post", + "pk": 3166, + "fields": { + "created": "2020-07-21T20:14:50.503Z", + "modified": "2020-07-21T20:14:50.536Z", + "title": "gomd - quickly display formatted markdown files with code highlight in your browser", + "body": "

      Hi all!

      \n\n

      I wanted to share a project I've been working on recently. I think it reached a stage where it's pretty usable and should work out of the box. gomd sets up a HTTP server and serves a directory in your browser so you can quickly view your markdown files. It comes with some neat features like:

      \n\n
        \n
      • Monitoring files - it will monitor files for changes and reload them whenever needed
      • \n
      • Hot reloading - whenever the file you are currently viewing changes, the tab in your browser will reload automatically.
      • \n
      • Code Highlight - All blocks of code in most common languages will be color highlighted.
      • \n
      • Themes - choose from multiple themes like: solarized, monokai, github, dracula...
      • \n
      \n\n

      Link: gomd

      \n\n

      For now its only available from AUR or built from source.

      \n\n

      \n\n

      Any tips or feedback will be greatly appreciated :)

      \n
      ", + "author": "wwojtekk", + "publication_date": "2020-07-21T20:07:31Z", + "url": "https://www.reddit.com/r/linux/comments/hvcg44/gomd_quickly_display_formatted_markdown_files/", + "read": false, + "rule": 80, + "remote_identifier": "hvcg44" + } +}, +{ + "model": "core.post", + "pk": 3167, + "fields": { + "created": "2020-07-21T20:14:50.503Z", + "modified": "2020-07-21T20:14:50.543Z", + "title": "They're not otherwise wrong, but it didn't become a real Internet standard until 2017.", + "body": "
      \"They're
      ", + "author": "foodown", + "publication_date": "2020-07-21T21:39:09Z", + "url": "https://www.reddit.com/r/linux/comments/hve7l5/theyre_not_otherwise_wrong_but_it_didnt_become_a/", + "read": false, + "rule": 80, + "remote_identifier": "hve7l5" + } +}, +{ + "model": "core.post", + "pk": 3168, + "fields": { + "created": "2020-07-21T20:14:50.503Z", + "modified": "2020-07-21T20:14:50.545Z", + "title": "Drawing - an alternative to Paint for Linux (gtk3, support HiDPI)", + "body": "", + "author": "dontdieych", + "publication_date": "2020-07-21T02:37:22Z", + "url": "https://www.reddit.com/r/linux/comments/huxgsg/drawing_an_alternative_to_paint_for_linux_gtk3/", + "read": false, + "rule": 80, + "remote_identifier": "huxgsg" + } +}, +{ + "model": "core.post", + "pk": 3169, + "fields": { + "created": "2020-07-21T20:14:50.509Z", + "modified": "2020-07-21T20:14:50.547Z", + "title": "Observations on a Linux issue with 3.5mm earphones with a mic", + "body": "

      Alright hello. I have come from r/SolusProject and I made a post there to do with headphone issues. I suggest you read through the post and comments to get a better understanding before reading this https://www.reddit.com/r/SolusProject/comments/hsql4d/frustrating_headphone_issues/. I had posted to do with it again, but it got taken down for duplication (when it wasn't duplication). This post is more of my observations from experimenting and such. There are distros I haven't tried but I tried a wide range of distros like manjaro, ubuntu based ones and all solus flavors, and I was looking more for how well they worked out of the box, rather than with fiddling around with pulse, hdajack etc which I know will work eventually. If you stumbled across this from searching about the same issue I have (or similar) or are confused to what this is about, I suggest you look at my previous post also.

      \n\n

      So anyways, I've tried the past few days mounting isos to usb drives and trying live os and installing various distros to see about the headphone issue. And my conclusion is that this issue affects the linux kernel in some way across the board. I don't really understand why completely but I have some kind of idea.

      \n\n

      From installing fresh distros, I noticed that the earphones (they are 3.5mm earphones + mic) get recognised as a microphone and not as a speaker system of some kind. Every single time I had a look at the sound settings and in pulse, they came up as plugged microphone, with the internal speakers being the only output device every single time. It's really odd seeing as how ubuntu 14.04 and xubuntu etc from years past worked flawlessly with the earphones, even manjaro a while ago on my older craptop worked fine. I don't really understand why it doesn't work on my device now.

      \n\n

      I'll leave my specs at the bottom of this post but what I think is is there's something the manufacturer did, or something like the cpu causes issue with linux. The manufacturer of my laptop is Lenovo, and the cpu/igpu is from AMD. A warning sign is that when installing a linux distro, it doesn't bring up the dual boot menu at startup like it should. Instead it completely hides the fact it exists until I use something like easyuefi to add an option for that distro, how it works is you specify the boot partition, whether it's linux or windows and the loader conf file for the distro. All of this hassle everytime doesn't appear on my craptop, because the dual boot menu appears flawlessly without issue. May be because it uses an Intel cpu/igpu unlike my newer laptop but it's hard to say.

      \n\n

      Also, it seems like the devices that appear in a given distro when looking at alsa, is hd generic devices but by reloading alsa or any command that shows the full name of the device, it says it's Intel. I don't know if that would be an issue, maybe amd use intel sound drivers or something. It's odd nonetheless.

      \n\n

      This issue has been boggling my mind for obvious reasons, with half-rhetorical questions like does linux not support the earphones anymore, whether out of accident from an overlooked bug in an update or intentionally phasing out? Is any of this AMD or Lenovo's fault? Even with proper headphones or something, will they fail? I don't think anyone here really knows, hell I'd bet an extreme that no one really understands why in the linux community. I kinda rambled in this post with stuff that should've been said in the last post/thread, but I'm saying it now.

      \n\n

      Thanks for contributing thus far to this discussion in figuring this out.

      \n\n

      Specs: AMD Ryzen 5 3500U Mobile CPU (2.2 - 3.7ghz quad core)

      \n\n

      Radeon Vega 8 Integrated GPU, 8GB Ram, 256GB SSD.

      \n\n

      Lenovo C340-14API Laptop

      \n
      ", + "author": "BrianMeerkatlol", + "publication_date": "2020-07-21T21:02:19Z", + "url": "https://www.reddit.com/r/linux/comments/hvdi3o/observations_on_a_linux_issue_with_35mm_earphones/", + "read": false, + "rule": 80, + "remote_identifier": "hvdi3o" + } +}, +{ + "model": "core.post", + "pk": 3170, + "fields": { + "created": "2020-07-21T20:14:50.509Z", + "modified": "2020-07-21T20:14:50.549Z", + "title": "South Korean distro HamoniKR OS has been added to Distrowatch", + "body": "", + "author": "TheHordeRisesAgain", + "publication_date": "2020-07-21T07:44:21Z", + "url": "https://www.reddit.com/r/linux/comments/hv1ug1/south_korean_distro_hamonikr_os_has_been_added_to/", + "read": false, + "rule": 80, + "remote_identifier": "hv1ug1" + } +}, +{ + "model": "core.post", + "pk": 3171, + "fields": { + "created": "2020-07-21T20:14:50.509Z", + "modified": "2020-07-21T20:14:50.559Z", + "title": "The Jailer is free! New release of the outstanding database subsetter and browser is available.", + "body": "", + "author": "Plane-Discussion", + "publication_date": "2020-07-21T12:53:54Z", + "url": "https://www.reddit.com/r/linux/comments/hv5b0j/the_jailer_is_free_new_release_of_the_outstanding/", + "read": false, + "rule": 80, + "remote_identifier": "hv5b0j" + } +}, +{ + "model": "core.post", + "pk": 3172, + "fields": { + "created": "2020-07-21T20:14:50.513Z", + "modified": "2020-07-21T20:14:50.563Z", + "title": "A few very well-aged excerpts from Microsoft\u2019s infamous 2004 \u201cGet the facts\u201d campaign, where they make the case for Windows servers being cheaper, more secure, and more performant than Linux servers", + "body": "
      \n

      Get the facts on Windows and Linux.

      \n\n

      Leading companies and third-party analysts confirm it: Windows has a lower total cost of ownership and outperforms Linux.

      \n\n

      ...

      \n\n

      -Security

      \n\n

      Windows Users Have Fewer Vulnerabilities

      \n
      \n\n

      And then literally the very next bullet point:

      \n\n
      \n

      -Featured Customer Case Study

      \n\n

      Equifax

      \n\n

      Equifax Sees 14 Percent Cost Savings

      \n\n

      Find out why Equifax, a global leader in transforming data into intelligence, selected Windows over Linux to enhance the speed and performance of its marketing services capabilities. Using Microsoft Windows Server System, the company has seen 14 percent in cost savings over Linux.

      \n
      \n\n

      Good thing they saved 14% and got all that extra security! Sure their website is janky and their login flow is downright horrifying (Check it out if you want to be amazed), but who could blame them? Linux is \u201cProhibitively Expensive, Extremely Complex, and Provides No Tangible Business Gains\u201d, Microsoft said so!

      \n\n

      Source: https://web.archive.org/web/20041027003759/http://www.microsoft.com/windowsserversystem/facts/default.mspx

      \n
      ", + "author": "kevinhaze", + "publication_date": "2020-07-20T21:42:15Z", + "url": "https://www.reddit.com/r/linux/comments/hus5lz/a_few_very_wellaged_excerpts_from_microsofts/", + "read": false, + "rule": 80, + "remote_identifier": "hus5lz" + } +}, +{ + "model": "core.post", + "pk": 3173, + "fields": { + "created": "2020-07-21T20:14:50.515Z", + "modified": "2020-07-21T20:14:50.566Z", + "title": "Are there are any professional audio recording studios or artists that use Linux?", + "body": "

      As the title says, who is using Linux as a professional audio engineer, producer, or artist? I am a former Mac user myself, and I am seeing people from time to time who have become disillusioned with what Apple has been doing for the past few years. However, I'm not sure if Linux really has a place for these people to land if they are serious about what they do.

      \n\n

      Fedora Design Suite and Ubuntu Studio are definitely encouraging to see, but what is their real-world usage like? Are we getting better with professional audio in Linux, or have things been stagnant for years?

      \n
      ", + "author": "RootHouston", + "publication_date": "2020-07-21T00:08:26Z", + "url": "https://www.reddit.com/r/linux/comments/huuxvq/are_there_are_any_professional_audio_recording/", + "read": false, + "rule": 80, + "remote_identifier": "huuxvq" + } +}, +{ + "model": "core.post", + "pk": 3174, + "fields": { + "created": "2020-07-21T20:14:50.515Z", + "modified": "2020-07-21T20:14:50.570Z", + "title": "When Linux had marketing", + "body": "", + "author": "Commodore256", + "publication_date": "2020-07-21T14:03:56Z", + "url": "https://www.reddit.com/r/linux/comments/hv65oa/when_linux_had_marketing/", + "read": false, + "rule": 80, + "remote_identifier": "hv65oa" + } +}, +{ + "model": "core.post", + "pk": 3175, + "fields": { + "created": "2020-07-21T20:14:50.520Z", + "modified": "2020-07-21T20:14:50.598Z", + "title": "Ward: Simple and minimalistic server dashboard", + "body": "

      Ward is a simple and and minimalistic server monitoring tool. Ward supports adaptive design system. Also it supports dark theme. It shows only principal information and can be used, if you want to see nice looking dashboard instead looking on bunch of numbers and graphs. Ward works nice on all popular operating systems, because it uses OSHI.

      \n\n

      https://preview.redd.it/gdppswc3a3c51.png?width=1448&format=png&auto=webp&s=0d6e10146c105ddcfd045dd59c970d4c127ddb8c

      \n\n

      https://github.com/B-Software/Ward

      \n
      ", + "author": "Pabyzu", + "publication_date": "2020-07-21T00:33:40Z", + "url": "https://www.reddit.com/r/linux/comments/huvea3/ward_simple_and_minimalistic_server_dashboard/", + "read": false, + "rule": 80, + "remote_identifier": "huvea3" + } +}, +{ + "model": "core.post", + "pk": 3176, + "fields": { + "created": "2020-07-21T20:14:50.522Z", + "modified": "2020-07-21T20:14:50.606Z", + "title": "WindowsFX - a good Windows alternative?", + "body": "

      I would personally like to hear some of your opinions (in the replies) about WindowsFX. What is WindowsFX you may ask? WindowsFX is a Brazilian linux distribution that is designed to look and act like Windows 10.

      \n\n

      Linux / WindowsFX is based off of Ubuntu, and uses Cinnamon as its DE. Upon first boot, normal Windows users can tell the difference. But if you were to put it in front of a non tech-savvy person, they wouldn't be able to tell the difference.

      \n\n

      Personally, with WSL on Windows, I see no need for a distro like this. However, as I said, I would like to hear your opinions on this distro.

      \n\n

      Video review here.

      \n
      ", + "author": "Demonitized101", + "publication_date": "2020-07-20T23:03:29Z", + "url": "https://www.reddit.com/r/linux/comments/hutpt5/windowsfx_a_good_windows_alternative/", + "read": false, + "rule": 80, + "remote_identifier": "hutpt5" + } +}, +{ + "model": "core.post", + "pk": 3177, + "fields": { + "created": "2020-07-21T20:14:50.775Z", + "modified": "2020-07-21T20:14:50.780Z", + "title": "Every day this good boy brings a carrot to his best buddy", + "body": "
      ", + "author": "TooShiftyForYou", + "publication_date": "2020-07-21T15:25:31Z", + "url": "https://www.reddit.com/r/aww/comments/hv7a8b/every_day_this_good_boy_brings_a_carrot_to_his/", + "read": false, + "rule": 81, + "remote_identifier": "hv7a8b" + } +}, +{ + "model": "core.post", + "pk": 3178, + "fields": { + "created": "2020-07-21T20:14:50.775Z", + "modified": "2020-07-25T20:08:34.264Z", + "title": "Kitten mimics his human petting the dog", + "body": "
      ", + "author": "SpecterAscendant", + "publication_date": "2020-07-21T14:56:57Z", + "url": "https://www.reddit.com/r/aww/comments/hv6ve3/kitten_mimics_his_human_petting_the_dog/", + "read": true, + "rule": 81, + "remote_identifier": "hv6ve3" + } +}, +{ + "model": "core.post", + "pk": 3179, + "fields": { + "created": "2020-07-21T20:14:50.775Z", + "modified": "2020-07-21T20:14:50.789Z", + "title": "My fox friend!", + "body": "
      ", + "author": "Zepantha", + "publication_date": "2020-07-21T14:27:25Z", + "url": "https://www.reddit.com/r/aww/comments/hv6gte/my_fox_friend/", + "read": false, + "rule": 81, + "remote_identifier": "hv6gte" + } +}, +{ + "model": "core.post", + "pk": 3180, + "fields": { + "created": "2020-07-21T20:14:50.775Z", + "modified": "2020-07-21T20:15:46.876Z", + "title": "Ducks annihilate peas", + "body": "
      ", + "author": "tommycalibre", + "publication_date": "2020-07-21T17:12:40Z", + "url": "https://www.reddit.com/r/aww/comments/hv9258/ducks_annihilate_peas/", + "read": true, + "rule": 81, + "remote_identifier": "hv9258" + } +}, +{ + "model": "core.post", + "pk": 3181, + "fields": { + "created": "2020-07-21T20:14:50.775Z", + "modified": "2020-07-21T20:14:50.797Z", + "title": "Wiggle it baby", + "body": "
      ", + "author": "neo_star", + "publication_date": "2020-07-21T18:44:31Z", + "url": "https://www.reddit.com/r/aww/comments/hvaucy/wiggle_it_baby/", + "read": false, + "rule": 81, + "remote_identifier": "hvaucy" + } +}, +{ + "model": "core.post", + "pk": 3182, + "fields": { + "created": "2020-07-21T20:14:50.776Z", + "modified": "2020-07-21T20:16:22.725Z", + "title": "I guess I should do this.. everyone seems to be liking little pups and kittens so.. Reddit, meet bailey", + "body": "
      \"I
      ", + "author": "X_XNOTHINGX_X", + "publication_date": "2020-07-21T14:15:08Z", + "url": "https://www.reddit.com/r/aww/comments/hv6b0a/i_guess_i_should_do_this_everyone_seems_to_be/", + "read": true, + "rule": 81, + "remote_identifier": "hv6b0a" + } +}, +{ + "model": "core.post", + "pk": 3183, + "fields": { + "created": "2020-07-21T20:14:50.776Z", + "modified": "2020-07-21T20:14:50.806Z", + "title": "The hat makes the crab.", + "body": "
      \"The
      ", + "author": "fujfuj", + "publication_date": "2020-07-21T14:48:40Z", + "url": "https://www.reddit.com/r/aww/comments/hv6rde/the_hat_makes_the_crab/", + "read": false, + "rule": 81, + "remote_identifier": "hv6rde" + } +}, +{ + "model": "core.post", + "pk": 3184, + "fields": { + "created": "2020-07-21T20:14:50.776Z", + "modified": "2020-07-21T20:14:50.812Z", + "title": "Baby bunny fits in hand", + "body": "
      ", + "author": "Hawken10", + "publication_date": "2020-07-21T12:31:30Z", + "url": "https://www.reddit.com/r/aww/comments/hv5253/baby_bunny_fits_in_hand/", + "read": false, + "rule": 81, + "remote_identifier": "hv5253" + } +}, +{ + "model": "core.post", + "pk": 3185, + "fields": { + "created": "2020-07-21T20:14:50.776Z", + "modified": "2020-07-21T20:14:50.818Z", + "title": "My cat and I, both pregnant", + "body": "
      \"My
      ", + "author": "nixdionisio", + "publication_date": "2020-07-21T11:06:25Z", + "url": "https://www.reddit.com/r/aww/comments/hv44m2/my_cat_and_i_both_pregnant/", + "read": false, + "rule": 81, + "remote_identifier": "hv44m2" + } +}, +{ + "model": "core.post", + "pk": 3186, + "fields": { + "created": "2020-07-21T20:14:50.776Z", + "modified": "2020-07-21T20:14:50.822Z", + "title": "Very sweet dance", + "body": "
      ", + "author": "Ashley1023", + "publication_date": "2020-07-21T13:03:03Z", + "url": "https://www.reddit.com/r/aww/comments/hv5ewq/very_sweet_dance/", + "read": false, + "rule": 81, + "remote_identifier": "hv5ewq" + } +}, +{ + "model": "core.post", + "pk": 3187, + "fields": { + "created": "2020-07-21T20:14:50.776Z", + "modified": "2020-07-21T20:14:50.825Z", + "title": "My local pet-store has a cat named Vegemite \u2764\ufe0f", + "body": "
      \"My
      ", + "author": "galinhad", + "publication_date": "2020-07-21T12:06:17Z", + "url": "https://www.reddit.com/r/aww/comments/hv4s5z/my_local_petstore_has_a_cat_named_vegemite/", + "read": false, + "rule": 81, + "remote_identifier": "hv4s5z" + } +}, +{ + "model": "core.post", + "pk": 3188, + "fields": { + "created": "2020-07-21T20:14:50.777Z", + "modified": "2020-07-21T20:15:01.459Z", + "title": "A teacher like that makes a huge difference", + "body": "
      ", + "author": "Unicornglitteryblood", + "publication_date": "2020-07-21T18:29:57Z", + "url": "https://www.reddit.com/r/aww/comments/hvajo9/a_teacher_like_that_makes_a_huge_difference/", + "read": true, + "rule": 81, + "remote_identifier": "hvajo9" + } +}, +{ + "model": "core.post", + "pk": 3189, + "fields": { + "created": "2020-07-21T20:14:50.777Z", + "modified": "2020-07-22T19:55:49.930Z", + "title": "Kitten Encounters Bubbly Water", + "body": "
      \"Kitten
      ", + "author": "DragonOBunny", + "publication_date": "2020-07-21T15:28:05Z", + "url": "https://www.reddit.com/r/aww/comments/hv7bis/kitten_encounters_bubbly_water/", + "read": true, + "rule": 81, + "remote_identifier": "hv7bis" + } +}, +{ + "model": "core.post", + "pk": 3190, + "fields": { + "created": "2020-07-21T20:14:50.777Z", + "modified": "2020-07-21T20:14:50.833Z", + "title": "Are These My Chickens Now?", + "body": "", + "author": "jasontaken", + "publication_date": "2020-07-21T09:55:36Z", + "url": "https://www.reddit.com/r/aww/comments/hv3de1/are_these_my_chickens_now/", + "read": false, + "rule": 81, + "remote_identifier": "hv3de1" + } +}, +{ + "model": "core.post", + "pk": 3191, + "fields": { + "created": "2020-07-21T20:14:50.777Z", + "modified": "2020-07-25T20:08:20.518Z", + "title": "Our St Bernard 6 months apart", + "body": "
      \"Our
      ", + "author": "ryan3105", + "publication_date": "2020-07-21T18:00:04Z", + "url": "https://www.reddit.com/r/aww/comments/hv9yea/our_st_bernard_6_months_apart/", + "read": true, + "rule": 81, + "remote_identifier": "hv9yea" + } +}, +{ + "model": "core.post", + "pk": 3192, + "fields": { + "created": "2020-07-21T20:14:50.777Z", + "modified": "2020-07-21T20:14:50.837Z", + "title": "Father and child in sync", + "body": "
      ", + "author": "Araragi_Monogatari", + "publication_date": "2020-07-21T08:29:18Z", + "url": "https://www.reddit.com/r/aww/comments/hv2enj/father_and_child_in_sync/", + "read": false, + "rule": 81, + "remote_identifier": "hv2enj" + } +}, +{ + "model": "core.post", + "pk": 3193, + "fields": { + "created": "2020-07-21T20:14:50.778Z", + "modified": "2020-07-21T20:14:50.840Z", + "title": "A meme is born", + "body": "
      \"A
      ", + "author": "Unicornglitteryblood", + "publication_date": "2020-07-21T18:55:04Z", + "url": "https://www.reddit.com/r/aww/comments/hvb1vh/a_meme_is_born/", + "read": false, + "rule": 81, + "remote_identifier": "hvb1vh" + } +}, +{ + "model": "core.post", + "pk": 3194, + "fields": { + "created": "2020-07-21T20:14:50.778Z", + "modified": "2020-07-21T20:14:50.842Z", + "title": "She bites, then she sleeps, then bites again, then sleeps again. \ud83d\ude02", + "body": "
      ", + "author": "earlymauvs", + "publication_date": "2020-07-21T11:34:19Z", + "url": "https://www.reddit.com/r/aww/comments/hv4fat/she_bites_then_she_sleeps_then_bites_again_then/", + "read": false, + "rule": 81, + "remote_identifier": "hv4fat" + } +}, +{ + "model": "core.post", + "pk": 3195, + "fields": { + "created": "2020-07-21T20:14:50.778Z", + "modified": "2020-07-21T20:14:50.844Z", + "title": "Nothing calmer that 2 ginger cats rubbing heads and showing their love in morning", + "body": "
      \"Nothing
      ", + "author": "Apotheosis33", + "publication_date": "2020-07-21T08:39:24Z", + "url": "https://www.reddit.com/r/aww/comments/hv2j2g/nothing_calmer_that_2_ginger_cats_rubbing_heads/", + "read": false, + "rule": 81, + "remote_identifier": "hv2j2g" + } +}, +{ + "model": "core.post", + "pk": 3196, + "fields": { + "created": "2020-07-21T20:14:50.778Z", + "modified": "2020-07-21T20:14:50.851Z", + "title": "Ring Tailed Possum", + "body": "", + "author": "Wayward-Delver", + "publication_date": "2020-07-21T11:23:51Z", + "url": "https://www.reddit.com/r/aww/comments/hv4b9e/ring_tailed_possum/", + "read": false, + "rule": 81, + "remote_identifier": "hv4b9e" + } +}, +{ + "model": "core.post", + "pk": 3197, + "fields": { + "created": "2020-07-21T20:14:50.778Z", + "modified": "2020-07-21T20:14:50.854Z", + "title": "Baby scooby in sad mood....", + "body": "
      \"Baby
      ", + "author": "deepanshuahiroo7", + "publication_date": "2020-07-21T15:12:23Z", + "url": "https://www.reddit.com/r/aww/comments/hv73ft/baby_scooby_in_sad_mood/", + "read": false, + "rule": 81, + "remote_identifier": "hv73ft" + } +}, +{ + "model": "core.post", + "pk": 3198, + "fields": { + "created": "2020-07-21T20:14:50.779Z", + "modified": "2020-07-21T20:14:50.856Z", + "title": "New friends!", + "body": "
      \"New
      ", + "author": "HelentotheKeller", + "publication_date": "2020-07-21T13:10:48Z", + "url": "https://www.reddit.com/r/aww/comments/hv5i6i/new_friends/", + "read": false, + "rule": 81, + "remote_identifier": "hv5i6i" + } +}, +{ + "model": "core.post", + "pk": 3199, + "fields": { + "created": "2020-07-21T20:14:50.779Z", + "modified": "2020-07-21T20:14:50.858Z", + "title": "When you haven't chewed anything for 1 second", + "body": "
      \"When
      ", + "author": "Tanay4", + "publication_date": "2020-07-21T10:26:53Z", + "url": "https://www.reddit.com/r/aww/comments/hv3pl0/when_you_havent_chewed_anything_for_1_second/", + "read": false, + "rule": 81, + "remote_identifier": "hv3pl0" + } +}, +{ + "model": "core.post", + "pk": 3200, + "fields": { + "created": "2020-07-21T20:14:50.779Z", + "modified": "2020-07-21T20:17:01.490Z", + "title": "Mango Derp", + "body": "
      \"Mango
      ", + "author": "sheetglass", + "publication_date": "2020-07-21T13:27:26Z", + "url": "https://www.reddit.com/r/aww/comments/hv5p8s/mango_derp/", + "read": true, + "rule": 81, + "remote_identifier": "hv5p8s" + } +}, +{ + "model": "core.post", + "pk": 3201, + "fields": { + "created": "2020-07-21T20:14:50.779Z", + "modified": "2020-07-21T20:14:50.863Z", + "title": "My guy turns 20 next month", + "body": "
      \"My
      ", + "author": "alozsoc", + "publication_date": "2020-07-21T06:34:26Z", + "url": "https://www.reddit.com/r/aww/comments/hv0xp1/my_guy_turns_20_next_month/", + "read": false, + "rule": 81, + "remote_identifier": "hv0xp1" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add log entry", + "content_type": [ + "admin", + "logentry" + ], + "codename": "add_logentry" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change log entry", + "content_type": [ + "admin", + "logentry" + ], + "codename": "change_logentry" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete log entry", + "content_type": [ + "admin", + "logentry" + ], + "codename": "delete_logentry" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view log entry", + "content_type": [ + "admin", + "logentry" + ], + "codename": "view_logentry" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add permission", + "content_type": [ + "auth", + "permission" + ], + "codename": "add_permission" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change permission", + "content_type": [ + "auth", + "permission" + ], + "codename": "change_permission" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete permission", + "content_type": [ + "auth", + "permission" + ], + "codename": "delete_permission" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view permission", + "content_type": [ + "auth", + "permission" + ], + "codename": "view_permission" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add group", + "content_type": [ + "auth", + "group" + ], + "codename": "add_group" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change group", + "content_type": [ + "auth", + "group" + ], + "codename": "change_group" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete group", + "content_type": [ + "auth", + "group" + ], + "codename": "delete_group" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view group", + "content_type": [ + "auth", + "group" + ], + "codename": "view_group" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add content type", + "content_type": [ + "contenttypes", + "contenttype" + ], + "codename": "add_contenttype" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change content type", + "content_type": [ + "contenttypes", + "contenttype" + ], + "codename": "change_contenttype" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete content type", + "content_type": [ + "contenttypes", + "contenttype" + ], + "codename": "delete_contenttype" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view content type", + "content_type": [ + "contenttypes", + "contenttype" + ], + "codename": "view_contenttype" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add session", + "content_type": [ + "sessions", + "session" + ], + "codename": "add_session" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change session", + "content_type": [ + "sessions", + "session" + ], + "codename": "change_session" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete session", + "content_type": [ + "sessions", + "session" + ], + "codename": "delete_session" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view session", + "content_type": [ + "sessions", + "session" + ], + "codename": "view_session" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add crontab", + "content_type": [ + "django_celery_beat", + "crontabschedule" + ], + "codename": "add_crontabschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change crontab", + "content_type": [ + "django_celery_beat", + "crontabschedule" + ], + "codename": "change_crontabschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete crontab", + "content_type": [ + "django_celery_beat", + "crontabschedule" + ], + "codename": "delete_crontabschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view crontab", + "content_type": [ + "django_celery_beat", + "crontabschedule" + ], + "codename": "view_crontabschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add interval", + "content_type": [ + "django_celery_beat", + "intervalschedule" + ], + "codename": "add_intervalschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change interval", + "content_type": [ + "django_celery_beat", + "intervalschedule" + ], + "codename": "change_intervalschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete interval", + "content_type": [ + "django_celery_beat", + "intervalschedule" + ], + "codename": "delete_intervalschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view interval", + "content_type": [ + "django_celery_beat", + "intervalschedule" + ], + "codename": "view_intervalschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add periodic task", + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "codename": "add_periodictask" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change periodic task", + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "codename": "change_periodictask" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete periodic task", + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "codename": "delete_periodictask" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view periodic task", + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "codename": "view_periodictask" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add periodic tasks", + "content_type": [ + "django_celery_beat", + "periodictasks" + ], + "codename": "add_periodictasks" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change periodic tasks", + "content_type": [ + "django_celery_beat", + "periodictasks" + ], + "codename": "change_periodictasks" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete periodic tasks", + "content_type": [ + "django_celery_beat", + "periodictasks" + ], + "codename": "delete_periodictasks" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view periodic tasks", + "content_type": [ + "django_celery_beat", + "periodictasks" + ], + "codename": "view_periodictasks" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add solar event", + "content_type": [ + "django_celery_beat", + "solarschedule" + ], + "codename": "add_solarschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change solar event", + "content_type": [ + "django_celery_beat", + "solarschedule" + ], + "codename": "change_solarschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete solar event", + "content_type": [ + "django_celery_beat", + "solarschedule" + ], + "codename": "delete_solarschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view solar event", + "content_type": [ + "django_celery_beat", + "solarschedule" + ], + "codename": "view_solarschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add clocked", + "content_type": [ + "django_celery_beat", + "clockedschedule" + ], + "codename": "add_clockedschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change clocked", + "content_type": [ + "django_celery_beat", + "clockedschedule" + ], + "codename": "change_clockedschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete clocked", + "content_type": [ + "django_celery_beat", + "clockedschedule" + ], + "codename": "delete_clockedschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view clocked", + "content_type": [ + "django_celery_beat", + "clockedschedule" + ], + "codename": "view_clockedschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add registration profile", + "content_type": [ + "registration", + "registrationprofile" + ], + "codename": "add_registrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change registration profile", + "content_type": [ + "registration", + "registrationprofile" + ], + "codename": "change_registrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete registration profile", + "content_type": [ + "registration", + "registrationprofile" + ], + "codename": "delete_registrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view registration profile", + "content_type": [ + "registration", + "registrationprofile" + ], + "codename": "view_registrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add supervised registration profile", + "content_type": [ + "registration", + "supervisedregistrationprofile" + ], + "codename": "add_supervisedregistrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change supervised registration profile", + "content_type": [ + "registration", + "supervisedregistrationprofile" + ], + "codename": "change_supervisedregistrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete supervised registration profile", + "content_type": [ + "registration", + "supervisedregistrationprofile" + ], + "codename": "delete_supervisedregistrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view supervised registration profile", + "content_type": [ + "registration", + "supervisedregistrationprofile" + ], + "codename": "view_supervisedregistrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add access attempt", + "content_type": [ + "axes", + "accessattempt" + ], + "codename": "add_accessattempt" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change access attempt", + "content_type": [ + "axes", + "accessattempt" + ], + "codename": "change_accessattempt" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete access attempt", + "content_type": [ + "axes", + "accessattempt" + ], + "codename": "delete_accessattempt" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view access attempt", + "content_type": [ + "axes", + "accessattempt" + ], + "codename": "view_accessattempt" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add access log", + "content_type": [ + "axes", + "accesslog" + ], + "codename": "add_accesslog" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change access log", + "content_type": [ + "axes", + "accesslog" + ], + "codename": "change_accesslog" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete access log", + "content_type": [ + "axes", + "accesslog" + ], + "codename": "delete_accesslog" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view access log", + "content_type": [ + "axes", + "accesslog" + ], + "codename": "view_accesslog" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add user", + "content_type": [ + "accounts", + "user" + ], + "codename": "add_user" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change user", + "content_type": [ + "accounts", + "user" + ], + "codename": "change_user" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete user", + "content_type": [ + "accounts", + "user" + ], + "codename": "delete_user" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view user", + "content_type": [ + "accounts", + "user" + ], + "codename": "view_user" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add post", + "content_type": [ + "core", + "post" + ], + "codename": "add_post" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change post", + "content_type": [ + "core", + "post" + ], + "codename": "change_post" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete post", + "content_type": [ + "core", + "post" + ], + "codename": "delete_post" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view post", + "content_type": [ + "core", + "post" + ], + "codename": "view_post" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add Category", + "content_type": [ + "core", + "category" + ], + "codename": "add_category" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change Category", + "content_type": [ + "core", + "category" + ], + "codename": "change_category" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete Category", + "content_type": [ + "core", + "category" + ], + "codename": "delete_category" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view Category", + "content_type": [ + "core", + "category" + ], + "codename": "view_category" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add collection rule", + "content_type": [ + "collection", + "collectionrule" + ], + "codename": "add_collectionrule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change collection rule", + "content_type": [ + "collection", + "collectionrule" + ], + "codename": "change_collectionrule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete collection rule", + "content_type": [ + "collection", + "collectionrule" + ], + "codename": "delete_collectionrule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view collection rule", + "content_type": [ + "collection", + "collectionrule" + ], + "codename": "view_collectionrule" + } +}, +{ + "model": "accounts.user", + "fields": { + "password": "pbkdf2_sha256$180000$U9a2CS9X0b8Y$T6bD/VoUOFoGNIp16aFlOL0N7q0e6A3I97ypm/AhsGo=", + "last_login": "2020-07-21T20:14:35.966Z", + "is_superuser": true, + "first_name": "", + "last_name": "", + "is_staff": true, + "is_active": true, + "date_joined": "2019-07-18T18:52:36.080Z", + "email": "sonny@bakker.nl", + "task": 10, + "reddit_refresh_token": null, + "reddit_access_token": null, + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "core.category", + "pk": 8, + "fields": { + "created": "2019-11-17T19:37:24.671Z", + "modified": "2019-11-18T19:59:55.010Z", + "name": "World news", + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "core.category", + "pk": 9, + "fields": { + "created": "2019-11-17T19:37:26.161Z", + "modified": "2020-05-30T13:36:10.509Z", + "name": "Tech", + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 3, + "fields": { + "created": "2019-07-14T13:08:10.374Z", + "modified": "2020-07-14T11:45:30.680Z", + "name": "Hackers News", + "type": "feed", + "url": "https://news.ycombinator.com/rss", + "website_url": "https://news.ycombinator.com/", + "favicon": "https://news.ycombinator.com/favicon.ico", + "timezone": "UTC", + "category": 9, + "last_run": "2020-07-14T11:45:30.477Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 4, + "fields": { + "created": "2019-07-20T11:24:32.745Z", + "modified": "2020-07-14T11:45:29.357Z", + "name": "BBC", + "type": "feed", + "url": "http://feeds.bbci.co.uk/news/world/rss.xml", + "website_url": "https://www.bbc.co.uk/news/", + "favicon": "https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png", + "timezone": "UTC", + "category": 8, + "last_run": "2020-07-14T11:45:28.863Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 5, + "fields": { + "created": "2019-07-20T11:24:50.411Z", + "modified": "2020-07-14T11:45:30.063Z", + "name": "Ars Technica", + "type": "feed", + "url": "http://feeds.arstechnica.com/arstechnica/index?fmt=xml", + "website_url": "https://arstechnica.com", + "favicon": "https://cdn.arstechnica.net/favicon.ico", + "timezone": "UTC", + "category": 9, + "last_run": "2020-07-14T11:45:29.810Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 6, + "fields": { + "created": "2019-07-20T11:25:02.089Z", + "modified": "2020-07-14T11:45:30.473Z", + "name": "The Guardian", + "type": "feed", + "url": "https://www.theguardian.com/world/rss", + "website_url": "https://www.theguardian.com/world", + "favicon": "https://assets.guim.co.uk/images/favicons/873381bf11d58e20f551905d51575117/72x72.png", + "timezone": "UTC", + "category": 8, + "last_run": "2020-07-14T11:45:30.181Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 7, + "fields": { + "created": "2019-07-20T11:25:30.121Z", + "modified": "2020-07-14T11:45:29.807Z", + "name": "Tweakers", + "type": "feed", + "url": "http://feeds.feedburner.com/tweakers/mixed?fmt=xml", + "website_url": "https://tweakers.net/", + "favicon": null, + "timezone": "UTC", + "category": 9, + "last_run": "2020-07-14T11:45:29.525Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 8, + "fields": { + "created": "2019-07-20T11:25:46.256Z", + "modified": "2020-07-14T11:45:30.179Z", + "name": "The Verge", + "type": "feed", + "url": "https://www.theverge.com/rss/index.xml", + "website_url": "https://www.theverge.com/", + "favicon": "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395367/favicon-16x16.0.png", + "timezone": "UTC", + "category": 9, + "last_run": "2020-07-14T11:45:30.066Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 9, + "fields": { + "created": "2019-11-24T15:28:41.399Z", + "modified": "2020-07-14T11:45:29.522Z", + "name": "NOS", + "type": "feed", + "url": "http://feeds.nos.nl/nosnieuwsalgemeen", + "website_url": null, + "favicon": null, + "timezone": "Europe/Amsterdam", + "category": 8, + "last_run": "2020-07-14T11:45:29.362Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 80, + "fields": { + "created": "2020-07-08T19:30:10.638Z", + "modified": "2020-07-21T20:14:50.609Z", + "name": "Linux subreddit", + "type": "subreddit", + "url": "https://oauth.reddit.com/r/linux/hot", + "website_url": null, + "favicon": null, + "timezone": "UTC", + "category": 9, + "last_run": "2020-07-21T20:14:50.492Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 81, + "fields": { + "created": "2020-07-08T19:30:33.590Z", + "modified": "2020-07-21T20:14:50.865Z", + "name": "AWW subreddit", + "type": "subreddit", + "url": "https://oauth.reddit.com/r/aww/hot", + "website_url": null, + "favicon": null, + "timezone": "UTC", + "category": 8, + "last_run": "2020-07-21T20:14:50.768Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 82, + "fields": { + "created": "2020-07-20T19:29:37.675Z", + "modified": "2020-07-21T20:14:50.489Z", + "name": "Star citizen subreddit", + "type": "subreddit", + "url": "https://oauth.reddit.com/r/starcitizen/hot.json", + "website_url": null, + "favicon": null, + "timezone": "UTC", + "category": 9, + "last_run": "2020-07-21T20:14:50.355Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "admin.logentry", + "pk": 1, + "fields": { + "action_time": "2020-05-24T18:38:44.624Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "django_celery_beat", + "intervalschedule" + ], + "object_id": "5", + "object_repr": "every 4 hours", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 2, + "fields": { + "action_time": "2020-05-24T18:38:46.689Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "object_id": "10", + "object_repr": "sonny@bakker.nl-collection-task: every 4 hours", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Interval Schedule\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 3, + "fields": { + "action_time": "2020-05-24T18:39:09.203Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "object_id": "26", + "object_repr": "sonnyba871@gmail.com-collection-task: every hour", + "action_flag": 3, + "change_message": "" + } +}, +{ + "model": "admin.logentry", + "pk": 4, + "fields": { + "action_time": "2020-05-24T19:46:50.248Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "object_id": "10", + "object_repr": "sonny@bakker.nl-collection-task: every 4 hours", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Positional Arguments\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 5, + "fields": { + "action_time": "2020-07-07T19:37:57.086Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "accounts", + "user" + ], + "object_id": "1", + "object_repr": "sonny@bakker.nl", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Reddit refresh token\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 6, + "fields": { + "action_time": "2020-07-07T19:39:46.160Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "object_id": "10", + "object_repr": "sonny@bakker.nl-collection-task: every 4 hours", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Task (registered)\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 7, + "fields": { + "action_time": "2020-07-08T19:29:27.025Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "object_id": "11", + "object_repr": "Reddit collection task: every 4 hours", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 8, + "fields": { + "action_time": "2020-07-14T11:46:50.039Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "accounts", + "user" + ], + "object_id": "1", + "object_repr": "sonny@bakker.nl", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Reddit access token\", \"Reddit refresh token\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 9, + "fields": { + "action_time": "2020-07-18T19:08:33.997Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "collection", + "collectionrule" + ], + "object_id": "81", + "object_repr": "AWW subreddit", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Url\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 10, + "fields": { + "action_time": "2020-07-18T19:08:44.063Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "collection", + "collectionrule" + ], + "object_id": "80", + "object_repr": "Linux subreddit", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Url\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 11, + "fields": { + "action_time": "2020-07-18T19:17:25.213Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "core", + "post" + ], + "object_id": "2336", + "object_repr": "Post-2336", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 12, + "fields": { + "action_time": "2020-07-18T19:17:40.596Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "core", + "post" + ], + "object_id": "2336", + "object_repr": "Post-2336", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 13, + "fields": { + "action_time": "2020-07-19T10:55:55.807Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "core", + "post" + ], + "object_id": "2764", + "object_repr": "Post-2764", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 14, + "fields": { + "action_time": "2020-07-19T10:57:40.643Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "core", + "post" + ], + "object_id": "2764", + "object_repr": "Post-2764", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 15, + "fields": { + "action_time": "2020-07-19T10:58:05.823Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "core", + "post" + ], + "object_id": "2764", + "object_repr": "Post-2764", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 16, + "fields": { + "action_time": "2020-07-26T09:51:52.478Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "accounts", + "user" + ], + "object_id": "1", + "object_repr": "sonny@bakker.nl", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"First name\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 17, + "fields": { + "action_time": "2020-07-26T09:52:04.691Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "accounts", + "user" + ], + "object_id": "1", + "object_repr": "sonny@bakker.nl", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"password\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 18, + "fields": { + "action_time": "2020-07-26T09:52:12.392Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "accounts", + "user" + ], + "object_id": "1", + "object_repr": "sonny@bakker.nl", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"First name\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 19, + "fields": { + "action_time": "2020-07-26T09:56:15.949Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "accounts", + "user" + ], + "object_id": "1", + "object_repr": "sonny@bakker.nl", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Reddit access token\", \"Reddit refresh token\"]}}]" + } +} +] diff --git a/src/newsreader/fixtures/local/fixture.json b/src/newsreader/fixtures/local/fixture.json index ffcc4fd..99176b5 100644 --- a/src/newsreader/fixtures/local/fixture.json +++ b/src/newsreader/fixtures/local/fixture.json @@ -47,7 +47,7 @@ "user" : 2, "succeeded" : true, "modified" : "2019-07-20T11:28:16.473Z", - "last_suceeded" : "2019-07-20T11:28:16.316Z", + "last_run" : "2019-07-20T11:28:16.316Z", "name" : "Hackers News", "website_url" : null, "created" : "2019-07-14T13:08:10.374Z", @@ -65,7 +65,7 @@ "error" : null, "user" : 2, "succeeded" : true, - "last_suceeded" : "2019-07-20T11:28:15.691Z", + "last_run" : "2019-07-20T11:28:15.691Z", "name" : "BBC", "modified" : "2019-07-20T12:07:49.164Z", "timezone" : "UTC", @@ -85,7 +85,7 @@ "website_url" : null, "name" : "Ars Technica", "succeeded" : true, - "last_suceeded" : "2019-07-20T11:28:15.986Z", + "last_run" : "2019-07-20T11:28:15.986Z", "modified" : "2019-07-20T11:28:16.033Z", "user" : 2 }, @@ -102,7 +102,7 @@ "user" : 2, "name" : "The Guardian", "succeeded" : true, - "last_suceeded" : "2019-07-20T11:28:16.078Z", + "last_run" : "2019-07-20T11:28:16.078Z", "modified" : "2019-07-20T12:07:44.292Z", "created" : "2019-07-20T11:25:02.089Z", "website_url" : null, @@ -119,7 +119,7 @@ "website_url" : null, "created" : "2019-07-20T11:25:30.121Z", "user" : 2, - "last_suceeded" : "2019-07-20T11:28:15.860Z", + "last_run" : "2019-07-20T11:28:15.860Z", "succeeded" : true, "modified" : "2019-07-20T12:07:28.473Z", "name" : "Tweakers" @@ -139,7 +139,7 @@ "website_url" : null, "timezone" : "UTC", "user" : 2, - "last_suceeded" : "2019-07-20T11:28:16.034Z", + "last_run" : "2019-07-20T11:28:16.034Z", "succeeded" : true, "modified" : "2019-07-20T12:07:21.704Z", "name" : "The Verge" diff --git a/src/newsreader/js/pages/categories/App.js b/src/newsreader/js/pages/categories/App.js index 691aaed..a035b46 100644 --- a/src/newsreader/js/pages/categories/App.js +++ b/src/newsreader/js/pages/categories/App.js @@ -69,6 +69,7 @@ class App extends React.Component { key={category.pk} category={category} showDialog={this.selectCategory} + updateUrl={this.props.updateUrl} /> ); }); @@ -80,7 +81,7 @@ class App extends React.Component { const pageHeader = ( <>

      Categories

      - + Create category diff --git a/src/newsreader/js/pages/categories/components/CategoryCard.js b/src/newsreader/js/pages/categories/components/CategoryCard.js index 94bd6f4..2e7cad4 100644 --- a/src/newsreader/js/pages/categories/components/CategoryCard.js +++ b/src/newsreader/js/pages/categories/components/CategoryCard.js @@ -33,7 +33,7 @@ const CategoryCard = props => { <> Edit diff --git a/src/newsreader/js/pages/categories/index.js b/src/newsreader/js/pages/categories/index.js index 9d75bb9..791fdbd 100644 --- a/src/newsreader/js/pages/categories/index.js +++ b/src/newsreader/js/pages/categories/index.js @@ -9,5 +9,15 @@ if (page) { const dataScript = document.getElementById('categories-data'); const categories = JSON.parse(dataScript.textContent); - ReactDOM.render(, page); + let createUrl = document.getElementById('createUrl').textContent; + let updateUrl = document.getElementById('updateUrl').textContent; + + ReactDOM.render( + , + page + ); } diff --git a/src/newsreader/js/pages/homepage/App.js b/src/newsreader/js/pages/homepage/App.js index 91cfa4e..77b6222 100644 --- a/src/newsreader/js/pages/homepage/App.js +++ b/src/newsreader/js/pages/homepage/App.js @@ -19,7 +19,11 @@ class App extends React.Component { return ( <> - + {this.props.error && ( @@ -30,6 +34,10 @@ class App extends React.Component { post={this.props.post} rule={this.props.rule} category={this.props.category} + feedUrl={this.props.feedUrl} + subredditUrl={this.props.subredditUrl} + timelineUrl={this.props.timelineUrl} + categoriesUrl={this.props.categoriesUrl} /> )} diff --git a/src/newsreader/js/pages/homepage/components/PostModal.js b/src/newsreader/js/pages/homepage/components/PostModal.js index 08033bc..5196102 100644 --- a/src/newsreader/js/pages/homepage/components/PostModal.js +++ b/src/newsreader/js/pages/homepage/components/PostModal.js @@ -3,7 +3,13 @@ import { connect } from 'react-redux'; import Cookies from 'js-cookie'; import { unSelectPost, markPostRead } from '../actions/posts.js'; -import { CATEGORY_TYPE, RULE_TYPE, FEED, SUBREDDIT } from '../constants.js'; +import { + CATEGORY_TYPE, + RULE_TYPE, + FEED, + SUBREDDIT, + TWITTER_TIMELINE, +} from '../constants.js'; import { formatDatetime } from '../../../utils.js'; class PostModal extends React.Component { @@ -44,10 +50,15 @@ class PostModal extends React.Component { const post = this.props.post; const publicationDate = formatDatetime(post.publicationDate); const titleClassName = post.read ? 'post__title post__title--read' : 'post__title'; - const ruleUrl = - this.props.rule.type === FEED - ? `/collection/rules/${this.props.rule.id}/` - : `/collection/rules/subreddits/${this.props.rule.id}/`; + let ruleUrl = ''; + + if (this.props.rule.type === SUBREDDIT) { + ruleUrl = `${this.props.subredditUrl}/${this.props.rule.id}/`; + } else if (this.props.rule.type === TWITTER_TIMELINE) { + ruleUrl = `${this.props.timelineUrl}/${this.props.rule.id}/`; + } else { + ruleUrl = `${this.props.feedUrl}/${this.props.rule.id}/`; + } return (
      @@ -66,7 +77,7 @@ class PostModal extends React.Component { {this.props.category && ( diff --git a/src/newsreader/js/pages/homepage/components/postlist/PostItem.js b/src/newsreader/js/pages/homepage/components/postlist/PostItem.js index 9b64289..f69a463 100644 --- a/src/newsreader/js/pages/homepage/components/postlist/PostItem.js +++ b/src/newsreader/js/pages/homepage/components/postlist/PostItem.js @@ -1,7 +1,13 @@ import React from 'react'; import { connect } from 'react-redux'; -import { CATEGORY_TYPE, RULE_TYPE, FEED, SUBREDDIT } from '../../constants.js'; +import { + CATEGORY_TYPE, + RULE_TYPE, + FEED, + SUBREDDIT, + TWITTER_TIMELINE, +} from '../../constants.js'; import { selectPost } from '../../actions/posts.js'; import { formatDatetime } from '../../../../utils.js'; @@ -13,11 +19,15 @@ class PostItem extends React.Component { const titleClassName = post.read ? 'posts__header posts__header--read' : 'posts__header'; + let ruleUrl = ''; - const ruleUrl = - rule.type === FEED - ? `/collection/rules/${rule.id}/` - : `/collection/rules/subreddits/${rule.id}/`; + if (rule.type === SUBREDDIT) { + ruleUrl = `${this.props.subredditUrl}/${rule.id}/`; + } else if (rule.type === TWITTER_TIMELINE) { + ruleUrl = `${this.props.timelineUrl}/${rule.id}/`; + } else { + ruleUrl = `${this.props.feedUrl}/${rule.id}/`; + } return (
    • diff --git a/src/newsreader/js/pages/homepage/components/postlist/PostList.js b/src/newsreader/js/pages/homepage/components/postlist/PostList.js index cd57d6d..cff2437 100644 --- a/src/newsreader/js/pages/homepage/components/postlist/PostList.js +++ b/src/newsreader/js/pages/homepage/components/postlist/PostList.js @@ -38,7 +38,16 @@ class PostList extends React.Component { render() { const postItems = this.props.postsBySection.map((item, index) => { - return ; + return ( + + ); }); if (isEqual(this.props.selected, {})) { diff --git a/src/newsreader/js/pages/homepage/constants.js b/src/newsreader/js/pages/homepage/constants.js index 66b6365..22184b9 100644 --- a/src/newsreader/js/pages/homepage/constants.js +++ b/src/newsreader/js/pages/homepage/constants.js @@ -3,3 +3,4 @@ export const CATEGORY_TYPE = 'CATEGORY'; export const SUBREDDIT = 'subreddit'; export const FEED = 'feed'; +export const TWITTER_TIMELINE = 'twitter_timeline'; diff --git a/src/newsreader/js/pages/homepage/index.js b/src/newsreader/js/pages/homepage/index.js index c16ed39..394a06c 100644 --- a/src/newsreader/js/pages/homepage/index.js +++ b/src/newsreader/js/pages/homepage/index.js @@ -11,9 +11,19 @@ const page = document.getElementById('homepage--page'); if (page) { const store = configureStore(); + let feedUrl = document.getElementById('feedUrl').textContent; + let subredditUrl = document.getElementById('subredditUrl').textContent; + let timelineUrl = document.getElementById('timelineUrl').textContent; + let categoriesUrl = document.getElementById('categoriesUrl').textContent; + ReactDOM.render( - + , page ); diff --git a/src/newsreader/news/collection/admin.py b/src/newsreader/news/collection/admin.py index c5a7c5c..ece5c23 100644 --- a/src/newsreader/news/collection/admin.py +++ b/src/newsreader/news/collection/admin.py @@ -6,14 +6,7 @@ from newsreader.news.collection.models import CollectionRule class CollectionRuleAdmin(admin.ModelAdmin): fields = ("url", "name", "timezone", "category", "favicon", "user") - list_display = ( - "name", - "type_display", - "category", - "url", - "last_suceeded", - "succeeded", - ) + list_display = ("name", "type_display", "category", "url", "last_run", "succeeded") list_filter = ("user",) def save_model(self, request, obj, form, change): diff --git a/src/newsreader/news/collection/base.py b/src/newsreader/news/collection/base.py index f980191..7286526 100644 --- a/src/newsreader/news/collection/base.py +++ b/src/newsreader/news/collection/base.py @@ -1,7 +1,10 @@ -from bs4 import BeautifulSoup +import bleach -from newsreader.news.collection.exceptions import StreamParseException -from newsreader.news.collection.utils import fetch +from newsreader.news.collection.constants import ( + WHITELISTED_ATTRIBUTES, + WHITELISTED_TAGS, +) +from newsreader.news.core.models import Post class Stream: @@ -20,19 +23,16 @@ class Stream: def parse(self, response): raise NotImplementedError - class Meta: - abstract = True - class Client: """ - Retrieves the data with streams + Retrieves the data through streams """ stream = Stream def __init__(self, rules=[]): - self.rules = rules if rules else CollectionRule.objects.enabled() + self.rules = rules def __enter__(self): for rule in self.rules: @@ -43,36 +43,40 @@ class Client: def __exit__(self, *args, **kwargs): pass - class Meta: - abstract = True - class Builder: """ - Creates the collected posts + Builds instances of various types """ instances = [] stream = None + payload = None - def __init__(self, stream): + def __init__(self, payload, stream): + self.payload = payload self.stream = stream def __enter__(self): - self.create_posts(self.stream) return self def __exit__(self, *args, **kwargs): pass - def create_posts(self, stream): - pass + def build(self): + raise NotImplementedError - def save(self): - pass + def sanitize_fragment(self, fragment): + if not fragment: + return "" - class Meta: - abstract = True + return bleach.clean( + fragment, + tags=WHITELISTED_TAGS, + attributes=WHITELISTED_ATTRIBUTES, + strip=True, + strip_comments=True, + ) class Collector: @@ -88,46 +92,54 @@ class Collector: self.builder = builder if builder else self.builder def collect(self, rules=None): - with self.client(rules=rules) as client: - for data, stream in client: - with self.builder((data, stream)) as builder: - builder.save() - - class Meta: - abstract = True + raise NotImplementedError -class WebsiteStream(Stream): - def __init__(self, url): - self.url = url +class Scheduler: + """ + Schedules rules according to certain ratelimitting + """ - def read(self): - response = fetch(self.url) - - return (self.parse(response.content), self) - - def parse(self, payload): - try: - return BeautifulSoup(payload, "lxml") - except TypeError: - raise StreamParseException("Could not parse given HTML") + def get_scheduled_rules(self): + raise NotImplementedError -class URLBuilder(Builder): +class PostBuilder(Builder): + rule_type = None + def __enter__(self): - return self + self.existing_posts = { + post.remote_identifier: post + for post in Post.objects.filter( + rule=self.stream.rule, rule__type=self.rule_type + ) + } - def build(self): - data, stream = self.stream - rule = stream.rule + return super().__enter__() - try: - url = data["feed"]["link"] - except (KeyError, TypeError): - url = None + def save(self): + for post in self.instances: + post.save() - if url: - rule.website_url = url - rule.save() - return rule, url +class PostStream(Stream): + rule_type = None + + +class PostClient(Client): + stream = PostStream + + def set_rule_error(self, rule, exception): + length = rule._meta.get_field("error").max_length + + rule.error = exception.message[-length:] + rule.succeeded = False + + +class PostCollector(Collector): + def collect(self, rules=[]): + with self.client(rules=rules) as client: + for payload, stream in client: + with self.builder(payload, stream) as builder: + builder.build() + builder.save() diff --git a/src/newsreader/news/collection/choices.py b/src/newsreader/news/collection/choices.py index 65f7ef5..612079c 100644 --- a/src/newsreader/news/collection/choices.py +++ b/src/newsreader/news/collection/choices.py @@ -5,3 +5,10 @@ from django.utils.translation import gettext as _ class RuleTypeChoices(TextChoices): feed = "feed", _("Feed") subreddit = "subreddit", _("Subreddit") + twitter_timeline = "twitter_timeline", _("Twitter timeline") + + +class TwitterPostTypeChoices(TextChoices): + photo = "photo", _("Photo") + video = "video", _("Video") + animated_gif = "animated_gif", _("GIF") diff --git a/src/newsreader/news/collection/constants.py b/src/newsreader/news/collection/constants.py index eade898..0c73642 100644 --- a/src/newsreader/news/collection/constants.py +++ b/src/newsreader/news/collection/constants.py @@ -23,6 +23,7 @@ WHITELISTED_TAGS = ( WHITELISTED_ATTRIBUTES = { **BLEACH_ATTRIBUTES, "a": ["href", "rel"], - "img": ["alt", "src"], - "source": ["srcset", "media", "src", "type"], + "img": ["alt", "src", "loading"], + "video": ["controls", "muted"], + "source": ["srcset", "src", "media", "type"], } diff --git a/src/newsreader/news/collection/favicon.py b/src/newsreader/news/collection/favicon.py index 44b96bf..639e7f6 100644 --- a/src/newsreader/news/collection/favicon.py +++ b/src/newsreader/news/collection/favicon.py @@ -1,16 +1,12 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from urllib.parse import urljoin, urlparse -from newsreader.news.collection.base import ( - Builder, - Client, - Collector, - Stream, - URLBuilder, - WebsiteStream, -) -from newsreader.news.collection.exceptions import StreamException +from bs4 import BeautifulSoup + +from newsreader.news.collection.base import Builder, Client, Collector, Stream +from newsreader.news.collection.exceptions import StreamException, StreamParseException from newsreader.news.collection.feed import FeedClient +from newsreader.news.collection.utils import fetch LINK_RELS = [ @@ -21,17 +17,45 @@ LINK_RELS = [ ] +class WebsiteStream(Stream): + def read(self): + response = fetch(self.rule.website_url) + + return self.parse(response.content), self + + def parse(self, payload): + try: + return BeautifulSoup(payload, features="lxml") + except TypeError: + raise StreamParseException("Could not parse given HTML") + + +class WebsiteURLBuilder(Builder): + def build(self): + try: + url = self.payload["feed"]["link"] + except (KeyError, TypeError): + url = None + + self.instances = [(self.stream, url)] if url else [] + + def save(self): + for stream, url in self.instances: + stream.rule.website_url = url + stream.rule.save() + + class FaviconBuilder(Builder): def build(self): - rule, soup = self.stream + rule = self.stream.rule - url = self.parse(soup, rule.website_url) + url = self.parse() - if url: - rule.favicon = url - rule.save() + self.instances = [(rule, url)] if url else [] + + def parse(self): + soup = self.payload - def parse(self, soup, website_url): if not soup.head: return @@ -44,9 +68,9 @@ class FaviconBuilder(Builder): parsed_url = urlparse(url) if not parsed_url.scheme and not parsed_url.netloc: - if not website_url: + if not self.stream.rule.website_url: return - return urljoin(website_url, url) + return urljoin(self.stream.rule.website_url, url) elif not parsed_url.scheme: return urljoin(f"https://{parsed_url.netloc}", parsed_url.path) @@ -73,6 +97,11 @@ class FaviconBuilder(Builder): elif icons: return icons.pop() + def save(self): + for rule, favicon_url in self.instances: + rule.favicon = favicon_url + rule.save() + class FaviconClient(Client): stream = WebsiteStream @@ -82,39 +111,35 @@ class FaviconClient(Client): def __enter__(self): with ThreadPoolExecutor(max_workers=10) as executor: - futures = { - executor.submit(stream.read): rule for rule, stream in self.streams - } + futures = [executor.submit(stream.read) for stream in self.streams] for future in as_completed(futures): - rule = futures[future] - try: - response_data, stream = future.result() + payload, stream = future.result() except StreamException: continue - yield (rule, response_data) + yield payload, stream class FaviconCollector(Collector): feed_client, favicon_client = (FeedClient, FaviconClient) - url_builder, favicon_builder = (URLBuilder, FaviconBuilder) + url_builder, favicon_builder = (WebsiteURLBuilder, FaviconBuilder) def collect(self, rules=None): streams = [] with self.feed_client(rules=rules) as client: - for data, stream in client: - with self.url_builder((data, stream)) as builder: - rule, url = builder.build() + for payload, stream in client: + with self.url_builder(payload, stream) as builder: + builder.build() + builder.save() - if not url: - continue - - streams.append((rule, WebsiteStream(url))) + if builder.instances: + streams.append(WebsiteStream(stream.rule)) with self.favicon_client(streams) as client: - for rule, data in client: - with self.favicon_builder((rule, data)) as builder: + for payload, stream in client: + with self.favicon_builder(payload, stream) as builder: builder.build() + builder.save() diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index f67a109..ae6cd42 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -6,17 +6,17 @@ from datetime import timedelta from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist from django.utils import timezone -import bleach import pytz from feedparser import parse -from newsreader.news.collection.base import Builder, Client, Collector, Stream -from newsreader.news.collection.choices import RuleTypeChoices -from newsreader.news.collection.constants import ( - WHITELISTED_ATTRIBUTES, - WHITELISTED_TAGS, +from newsreader.news.collection.base import ( + PostBuilder, + PostClient, + PostCollector, + PostStream, ) +from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.exceptions import ( StreamDeniedException, StreamException, @@ -24,7 +24,6 @@ from newsreader.news.collection.exceptions import ( StreamParseException, StreamTimeOutException, ) -from newsreader.news.collection.models import CollectionRule from newsreader.news.collection.utils import ( build_publication_date, fetch, @@ -36,32 +35,10 @@ from newsreader.news.core.models import Post logger = logging.getLogger(__name__) -class FeedBuilder(Builder): - instances = [] +class FeedBuilder(PostBuilder): + rule__type = RuleTypeChoices.feed - def __enter__(self): - _, stream = self.stream - - self.instances = [] - self.existing_posts = { - post.remote_identifier: post - for post in Post.objects.filter( - rule=stream.rule, rule__type=RuleTypeChoices.feed - ) - } - - return super().__enter__() - - def create_posts(self, stream): - data, stream = stream - - with FeedDuplicateHandler(stream.rule) as duplicate_handler: - entries = data.get("entries", []) - - instances = self.build(entries, stream.rule) - self.instances = duplicate_handler.check(instances) - - def build(self, entries, rule): + def build(self): field_mapping = { "id": "remote_identifier", "title": "title", @@ -70,56 +47,47 @@ class FeedBuilder(Builder): "published_parsed": "publication_date", "author": "author", } + tz = pytz.timezone(self.stream.rule.timezone) + instances = [] - tz = pytz.timezone(rule.timezone) + with FeedDuplicateHandler(self.stream.rule) as duplicate_handler: + entries = self.payload.get("entries", []) - for entry in entries: - data = {"rule_id": rule.pk} + for entry in entries: + data = {"rule_id": self.stream.rule.pk} - for field, model_field in field_mapping.items(): - if not field in entry: - continue + for field, model_field in field_mapping.items(): + if not field in entry: + continue - value = truncate_text(Post, model_field, entry[field]) + value = truncate_text(Post, model_field, entry[field]) - if field == "published_parsed": - data[model_field] = build_publication_date(value, tz) - elif field == "summary": - data[model_field] = self.sanitize_fragment(value) - else: - data[model_field] = value + if field == "published_parsed": + data[model_field] = build_publication_date(value, tz) + elif field == "summary": + data[model_field] = self.sanitize_fragment(value) + else: + data[model_field] = value - if "content" in entry: - content = self.get_content(entry["content"]) - body = data.get("body", "") + if "content" in entry: + content = self.get_content(entry["content"]) + body = data.get("body", "") - if not body or len(body) < len(content): - data["body"] = content + if not body or len(body) < len(content): + data["body"] = content - yield Post(**data) + instances.append(Post(**data)) - def sanitize_fragment(self, fragment): - if not fragment: - return "" - - return bleach.clean( - fragment, - tags=WHITELISTED_TAGS, - attributes=WHITELISTED_ATTRIBUTES, - strip=True, - strip_comments=True, - ) + self.instances = duplicate_handler.check(instances) def get_content(self, items): content = "\n ".join([item.get("value") for item in items]) return self.sanitize_fragment(content) - def save(self): - for post in self.instances: - post.save() +class FeedStream(PostStream): + rule_type = RuleTypeChoices.feed -class FeedStream(Stream): def read(self): response = fetch(self.rule.url) @@ -133,17 +101,9 @@ class FeedStream(Stream): raise StreamParseException(response=response, message=message) from e -class FeedClient(Client): +class FeedClient(PostClient): stream = FeedStream - def __init__(self, rules=[]): - if rules: - self.rules = rules - else: - self.rules = CollectionRule.objects.filter( - enabled=True, type=RuleTypeChoices.feed - ) - def __enter__(self): streams = [self.stream(rule) for rule in self.rules] @@ -154,13 +114,12 @@ class FeedClient(Client): stream = futures[future] try: - response_data = future.result() + payload = future.result() stream.rule.error = None stream.rule.succeeded = True - stream.rule.last_suceeded = timezone.now() - yield response_data + yield payload except (StreamNotFoundException, StreamTimeOutException) as e: logger.warning(f"Request failed for {stream.rule.url}") @@ -174,16 +133,11 @@ class FeedClient(Client): continue finally: + stream.rule.last_run = timezone.now() stream.rule.save() - def set_rule_error(self, rule, exception): - length = rule._meta.get_field("error").max_length - rule.error = exception.message[-length:] - rule.succeeded = False - - -class FeedCollector(Collector): +class FeedCollector(PostCollector): builder = FeedBuilder client = FeedClient diff --git a/src/newsreader/news/collection/forms/__init__.py b/src/newsreader/news/collection/forms/__init__.py new file mode 100644 index 0000000..88a51c7 --- /dev/null +++ b/src/newsreader/news/collection/forms/__init__.py @@ -0,0 +1,4 @@ +from newsreader.news.collection.forms.feed import FeedForm, OPMLImportForm +from newsreader.news.collection.forms.reddit import SubRedditForm +from newsreader.news.collection.forms.rules import CollectionRuleBulkForm +from newsreader.news.collection.forms.twitter import TwitterTimelineForm diff --git a/src/newsreader/news/collection/forms/base.py b/src/newsreader/news/collection/forms/base.py new file mode 100644 index 0000000..da23659 --- /dev/null +++ b/src/newsreader/news/collection/forms/base.py @@ -0,0 +1,29 @@ +from django import forms + +from newsreader.news.collection.models import CollectionRule +from newsreader.news.core.models import Category + + +class CollectionRuleForm(forms.ModelForm): + category = forms.ModelChoiceField(required=False, queryset=Category.objects.all()) + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop("user") + + super().__init__(*args, **kwargs) + + self.fields["category"].queryset = Category.objects.filter(user=self.user) + + def save(self, commit=True): + instance = super().save(commit=False) + instance.user = self.user + + if commit: + instance.save() + self.save_m2m() + + return instance + + class Meta: + model = CollectionRule + fields = "__all__" diff --git a/src/newsreader/news/collection/forms/feed.py b/src/newsreader/news/collection/forms/feed.py new file mode 100644 index 0000000..4a22a2e --- /dev/null +++ b/src/newsreader/news/collection/forms/feed.py @@ -0,0 +1,28 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ + +import pytz + +from newsreader.core.forms import CheckboxInput +from newsreader.news.collection.forms.base import CollectionRuleForm +from newsreader.news.collection.models import CollectionRule + + +class FeedForm(CollectionRuleForm): + timezone = forms.ChoiceField( + widget=forms.Select(attrs={"size": len(pytz.all_timezones)}), + choices=((timezone, timezone) for timezone in pytz.all_timezones), + help_text=_("The timezone which the feed uses"), + initial=pytz.utc, + ) + + class Meta: + model = CollectionRule + fields = ("name", "url", "timezone", "favicon", "category") + + +class OPMLImportForm(forms.Form): + file = forms.FileField(allow_empty_file=False) + skip_existing = forms.BooleanField( + initial=False, required=False, widget=CheckboxInput + ) diff --git a/src/newsreader/news/collection/forms.py b/src/newsreader/news/collection/forms/reddit.py similarity index 51% rename from src/newsreader/news/collection/forms.py rename to src/newsreader/news/collection/forms/reddit.py index c79a867..0bcde9f 100644 --- a/src/newsreader/news/collection/forms.py +++ b/src/newsreader/news/collection/forms/reddit.py @@ -9,6 +9,7 @@ from newsreader.core.forms import CheckboxInput from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.models import CollectionRule from newsreader.news.collection.reddit import REDDIT_API_URL +from newsreader.news.collection.forms.base import CollectionRuleForm from newsreader.news.core.models import Category @@ -22,53 +23,9 @@ def get_reddit_help_text(): ) -class CollectionRuleForm(forms.ModelForm): - category = forms.ModelChoiceField(required=False, queryset=Category.objects.all()) - timezone = forms.ChoiceField( - widget=forms.Select(attrs={"size": len(pytz.all_timezones)}), - choices=((timezone, timezone) for timezone in pytz.all_timezones), - help_text=_("The timezone which the feed uses"), - initial=pytz.utc, - ) - - def __init__(self, *args, **kwargs): - self.user = kwargs.pop("user") - - super().__init__(*args, **kwargs) - - self.fields["category"].queryset = Category.objects.filter(user=self.user) - - def save(self, commit=True): - instance = super().save(commit=False) - instance.user = self.user - - if commit: - instance.save() - self.save_m2m() - - return instance - - class Meta: - model = CollectionRule - fields = ("name", "url", "timezone", "favicon", "category") - - -class CollectionRuleBulkForm(forms.Form): - rules = forms.ModelMultipleChoiceField(queryset=CollectionRule.objects.none()) - - def __init__(self, user, *args, **kwargs): - self.user = user - - super().__init__(*args, **kwargs) - - self.fields["rules"].queryset = CollectionRule.objects.filter(user=user) - - -class SubRedditRuleForm(CollectionRuleForm): +class SubRedditForm(CollectionRuleForm): url = forms.URLField(max_length=1024, help_text=get_reddit_help_text) - timezone = None - def clean_url(self): url = self.cleaned_data["url"] @@ -92,10 +49,3 @@ class SubRedditRuleForm(CollectionRuleForm): class Meta: model = CollectionRule fields = ("name", "url", "favicon", "category") - - -class OPMLImportForm(forms.Form): - file = forms.FileField(allow_empty_file=False) - skip_existing = forms.BooleanField( - initial=False, required=False, widget=CheckboxInput - ) diff --git a/src/newsreader/news/collection/forms/rules.py b/src/newsreader/news/collection/forms/rules.py new file mode 100644 index 0000000..fade945 --- /dev/null +++ b/src/newsreader/news/collection/forms/rules.py @@ -0,0 +1,14 @@ +from django import forms + +from newsreader.news.collection.models import CollectionRule + + +class CollectionRuleBulkForm(forms.Form): + rules = forms.ModelMultipleChoiceField(queryset=CollectionRule.objects.none()) + + def __init__(self, user, *args, **kwargs): + self.user = user + + super().__init__(*args, **kwargs) + + self.fields["rules"].queryset = CollectionRule.objects.filter(user=user) diff --git a/src/newsreader/news/collection/forms/twitter.py b/src/newsreader/news/collection/forms/twitter.py new file mode 100644 index 0000000..902652b --- /dev/null +++ b/src/newsreader/news/collection/forms/twitter.py @@ -0,0 +1,35 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ + +import pytz + +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.forms.base import CollectionRuleForm +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.twitter import TWITTER_API_URL + + +class TwitterTimelineForm(CollectionRuleForm): + screen_name = forms.CharField( + max_length=255, + label=_("Twitter profile name"), + help_text=_("Profile name without hashtags"), + required=True, + ) + + def save(self, commit=True): + instance = super().save(commit=False) + + instance.type = RuleTypeChoices.twitter_timeline + instance.timezone = str(pytz.utc) + instance.url = f"{TWITTER_API_URL}/statuses/user_timeline.json?screen_name={instance.screen_name}&tweet_mode=extended" + + if commit: + instance.save() + self.save_m2m() + + return instance + + class Meta: + model = CollectionRule + fields = ("name", "screen_name", "favicon", "category") diff --git a/src/newsreader/news/collection/migrations/0009_auto_20200807_2030.py b/src/newsreader/news/collection/migrations/0009_auto_20200807_2030.py new file mode 100644 index 0000000..2ce4cb3 --- /dev/null +++ b/src/newsreader/news/collection/migrations/0009_auto_20200807_2030.py @@ -0,0 +1,29 @@ +# Generated by Django 3.0.7 on 2020-08-07 18:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("collection", "0008_collectionrule_type")] + + operations = [ + migrations.AddField( + model_name="collectionrule", + name="screen_name", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name="collectionrule", + name="type", + field=models.CharField( + choices=[ + ("feed", "Feed"), + ("subreddit", "Subreddit"), + ("twitter", "Twitter"), + ], + default="feed", + max_length=20, + ), + ), + ] diff --git a/src/newsreader/news/collection/migrations/0010_auto_20200913_2101.py b/src/newsreader/news/collection/migrations/0010_auto_20200913_2101.py new file mode 100644 index 0000000..2f08f6e --- /dev/null +++ b/src/newsreader/news/collection/migrations/0010_auto_20200913_2101.py @@ -0,0 +1,24 @@ +# Generated by Django 3.0.7 on 2020-09-13 19:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("collection", "0009_auto_20200807_2030")] + + operations = [ + migrations.AlterField( + model_name="collectionrule", + name="type", + field=models.CharField( + choices=[ + ("feed", "Feed"), + ("subreddit", "Subreddit"), + ("twitter_timeline", "Twitter timeline"), + ], + default="feed", + max_length=20, + ), + ) + ] diff --git a/src/newsreader/news/collection/migrations/0011_auto_20200913_2157.py b/src/newsreader/news/collection/migrations/0011_auto_20200913_2157.py new file mode 100644 index 0000000..308c654 --- /dev/null +++ b/src/newsreader/news/collection/migrations/0011_auto_20200913_2157.py @@ -0,0 +1,14 @@ +# Generated by Django 3.0.7 on 2020-09-13 19:57 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [("collection", "0010_auto_20200913_2101")] + + operations = [ + migrations.RenameField( + model_name="collectionrule", old_name="last_suceeded", new_name="last_run" + ) + ] diff --git a/src/newsreader/news/collection/models.py b/src/newsreader/news/collection/models.py index 35841ba..92dfe51 100644 --- a/src/newsreader/news/collection/models.py +++ b/src/newsreader/news/collection/models.py @@ -41,9 +41,8 @@ class CollectionRule(TimeStampedModel): on_delete=models.SET_NULL, ) - last_suceeded = models.DateTimeField(blank=True, null=True) + last_run = models.DateTimeField(blank=True, null=True) succeeded = models.BooleanField(default=False) - error = models.CharField(max_length=1024, blank=True, null=True) enabled = models.BooleanField( @@ -57,6 +56,9 @@ class CollectionRule(TimeStampedModel): on_delete=models.CASCADE, ) + # Twitter + screen_name = models.CharField(max_length=255, blank=True, null=True) + objects = CollectionRuleQuerySet.as_manager() def __str__(self): @@ -66,5 +68,13 @@ class CollectionRule(TimeStampedModel): def update_url(self): if self.type == RuleTypeChoices.subreddit: return reverse("news:collection:subreddit-update", kwargs={"pk": self.pk}) + elif self.type == RuleTypeChoices.twitter_timeline: + return reverse( + "news:collection:twitter-timeline-update", kwargs={"pk": self.pk} + ) - return reverse("news:collection:rule-update", kwargs={"pk": self.pk}) + return reverse("news:collection:feed-update", kwargs={"pk": self.pk}) + + @property + def failed(self): + return not self.succeeded and self.last_run diff --git a/src/newsreader/news/collection/reddit.py b/src/newsreader/news/collection/reddit.py index 557271c..daeb85f 100644 --- a/src/newsreader/news/collection/reddit.py +++ b/src/newsreader/news/collection/reddit.py @@ -12,11 +12,16 @@ from django.core.cache import cache from django.utils import timezone from django.utils.html import format_html -import bleach import pytz import requests -from newsreader.news.collection.base import Builder, Client, Collector, Stream +from newsreader.news.collection.base import ( + PostBuilder, + PostClient, + PostCollector, + PostStream, + Scheduler, +) from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.constants import ( WHITELISTED_ATTRIBUTES, @@ -93,32 +98,32 @@ def get_reddit_access_token(code, user): return response_data["access_token"], response_data["refresh_token"] -class RedditBuilder(Builder): - def __enter__(self): - _, stream = self.stream +# Note that the API always returns 204's with correct basic auth headers +def revoke_reddit_token(user): + client_auth = requests.auth.HTTPBasicAuth( + settings.REDDIT_CLIENT_ID, settings.REDDIT_CLIENT_SECRET + ) - self.instances = [] - self.existing_posts = { - post.remote_identifier: post - for post in Post.objects.filter( - rule=stream.rule, rule__type=RuleTypeChoices.subreddit - ) - } + response = post( + f"{REDDIT_URL}/api/v1/revoke_token", + data={"token": user.reddit_refresh_token, "token_type_hint": "refresh_token"}, + auth=client_auth, + ) - return super().__enter__() + return response.status_code == 204 - def create_posts(self, stream): - data, stream = stream - posts = [] - if not "data" in data or not "children" in data["data"]: +class RedditBuilder(PostBuilder): + rule_type = RuleTypeChoices.subreddit + + def build(self): + results = {} + + if not "data" in self.payload or not "children" in self.payload["data"]: return - posts = data["data"]["children"] - self.instances = self.build(posts, stream.rule) - - def build(self, posts, rule): - results = {} + posts = self.payload["data"]["children"] + rule = self.stream.rule for post in posts: if not "data" in post or post["kind"] != REDDIT_POST: @@ -139,17 +144,7 @@ class RedditBuilder(Builder): if is_text_post: uncleaned_body = data["selftext_html"] unescaped_body = unescape(uncleaned_body) if uncleaned_body else "" - body = ( - bleach.clean( - unescaped_body, - tags=WHITELISTED_TAGS, - attributes=WHITELISTED_ATTRIBUTES, - strip=True, - strip_comments=True, - ) - if unescaped_body - else "" - ) + body = self.sanitize_fragment(unescaped_body) if unescaped_body else "" elif direct_url.endswith(REDDIT_IMAGE_EXTENSIONS): body = format_html( "
      {title}
      ", @@ -192,7 +187,9 @@ class RedditBuilder(Builder): parsed_date = datetime.fromtimestamp(post["data"]["created_utc"]) created_date = pytz.utc.localize(parsed_date) except (OverflowError, OSError): - logging.warning(f"Failed parsing timestamp from {url_fragment}") + logging.warning( + f"Failed parsing timestamp from {REDDIT_URL}{post_url_fragment}" + ) created_date = timezone.now() post_data = { @@ -216,14 +213,98 @@ class RedditBuilder(Builder): results[remote_identifier] = Post(**post_data) - return results.values() - - def save(self): - for post in self.instances: - post.save() + self.instances = results.values() -class RedditScheduler: +class RedditStream(PostStream): + rule_type = RuleTypeChoices.subreddit + headers = {} + + def __init__(self, rule): + super().__init__(rule) + + self.headers = { + f"Authorization": f"bearer {self.rule.user.reddit_access_token}" + } + + def read(self): + response = fetch(self.rule.url, headers=self.headers) + + return self.parse(response), self + + def parse(self, response): + try: + return response.json() + except JSONDecodeError as e: + raise StreamParseException( + response=response, message="Failed parsing json" + ) from e + + +class RedditClient(PostClient): + stream = RedditStream + + def __enter__(self): + streams = [[self.stream(rule) for rule in batch] for batch in self.rules] + rate_limitted = False + + with ThreadPoolExecutor(max_workers=10) as executor: + for batch in streams: + futures = {executor.submit(stream.read): stream for stream in batch} + + if rate_limitted: + logger.warning("Aborting requests, ratelimit hit") + break + + for future in as_completed(futures): + stream = futures[future] + + try: + response_data = future.result() + + stream.rule.error = None + stream.rule.succeeded = True + + yield response_data + except StreamDeniedException as e: + logger.warning( + f"Access token expired for user {stream.rule.user.pk}" + ) + + stream.rule.user.reddit_access_token = None + stream.rule.user.save() + + self.set_rule_error(stream.rule, e) + + RedditTokenTask.delay(stream.rule.user.pk) + + break + except StreamTooManyException as e: + logger.exception("Ratelimit hit, aborting batched subreddits") + + self.set_rule_error(stream.rule, e) + + rate_limitted = True + break + except StreamException as e: + logger.exception( + f"Stream failed reading content from {stream.rule.url}" + ) + + self.set_rule_error(stream.rule, e) + + continue + finally: + stream.rule.last_run = timezone.now() + stream.rule.save() + + +class RedditCollector(PostCollector): + builder = RedditBuilder + client = RedditClient + + +class RedditScheduler(Scheduler): max_amount = RATE_LIMIT max_user_amount = RATE_LIMIT / 4 @@ -234,7 +315,7 @@ class RedditScheduler: user__reddit_access_token__isnull=False, user__reddit_refresh_token__isnull=False, enabled=True, - ).order_by("last_suceeded")[:200] + ).order_by("last_run")[:200] else: self.subreddits = subreddits @@ -263,100 +344,3 @@ class RedditScheduler: current_amount += 1 return list(rule_mapping.values()) - - -class RedditStream(Stream): - headers = {} - user = None - - def __init__(self, rule): - super().__init__(rule) - - self.user = self.rule.user - self.headers = { - f"Authorization": f"bearer {self.rule.user.reddit_access_token}" - } - - def read(self): - response = fetch(self.rule.url, headers=self.headers) - - return self.parse(response), self - - def parse(self, response): - try: - return response.json() - except JSONDecodeError as e: - raise StreamParseException( - response=response, message=f"Failed parsing json" - ) from e - - -class RedditClient(Client): - stream = RedditStream - - def __init__(self, rules=[]): - self.rules = rules - - def __enter__(self): - streams = [[self.stream(rule) for rule in batch] for batch in self.rules] - rate_limitted = False - - with ThreadPoolExecutor(max_workers=10) as executor: - for batch in streams: - futures = {executor.submit(stream.read): stream for stream in batch} - - if rate_limitted: - break - - for future in as_completed(futures): - stream = futures[future] - - try: - response_data = future.result() - - stream.rule.error = None - stream.rule.succeeded = True - stream.rule.last_suceeded = timezone.now() - - yield response_data - except StreamDeniedException as e: - logger.warning( - f"Access token expired for user {stream.user.pk}" - ) - - stream.rule.user.reddit_access_token = None - stream.rule.user.save() - - self.set_rule_error(stream.rule, e) - - RedditTokenTask.delay(stream.rule.user.pk) - - break - except StreamTooManyException as e: - logger.exception("Ratelimit hit, aborting batched subreddits") - - self.set_rule_error(stream.rule, e) - - rate_limitted = True - break - except StreamException as e: - logger.exception( - "Stream failed reading content from " f"{stream.rule.url}" - ) - - self.set_rule_error(stream.rule, e) - - continue - finally: - stream.rule.save() - - def set_rule_error(self, rule, exception): - length = rule._meta.get_field("error").max_length - - rule.error = exception.message[-length:] - rule.succeeded = False - - -class RedditCollector(Collector): - builder = RedditBuilder - client = RedditClient diff --git a/src/newsreader/news/collection/tasks.py b/src/newsreader/news/collection/tasks.py index a04c5f9..926b05b 100644 --- a/src/newsreader/news/collection/tasks.py +++ b/src/newsreader/news/collection/tasks.py @@ -114,6 +114,40 @@ class RedditTokenTask(app.Task): user.save() +class TwitterTimelineTask(app.Task): + name = "TwitterTimelineTask" + ignore_result = True + + def run(self, user_pk): + from newsreader.news.collection.twitter import ( + TwitterCollector, + TwitterTimeLineScheduler, + ) + + try: + user = User.objects.get(pk=user_pk) + except ObjectDoesNotExist: + message = f"User {user_pk} does not exist" + logger.exception(message) + + raise Reject(reason=message, requeue=False) + + with MemCacheLock("f{user.email}-timeline-task", self.app.oid) as acquired: + if acquired: + logger.info(f"Running twitter timeline task for user {user_pk}") + + scheduler = TwitterTimeLineScheduler(user) + timelines = scheduler.get_scheduled_rules() + + collector = TwitterCollector() + collector.collect(rules=timelines) + else: + logger.warning(f"Cancelling task due to existing lock") + + raise Reject(reason="Task already running", requeue=False) + + FeedTask = app.register_task(FeedTask()) RedditTask = app.register_task(RedditTask()) RedditTokenTask = app.register_task(RedditTokenTask()) +TwitterTimelineTask = app.register_task(TwitterTimelineTask()) diff --git a/src/newsreader/news/collection/templates/news/collection/views/rule-create.html b/src/newsreader/news/collection/templates/news/collection/views/feed-create.html similarity index 78% rename from src/newsreader/news/collection/templates/news/collection/views/rule-create.html rename to src/newsreader/news/collection/templates/news/collection/views/feed-create.html index 82ed6c5..c24791a 100644 --- a/src/newsreader/news/collection/templates/news/collection/views/rule-create.html +++ b/src/newsreader/news/collection/templates/news/collection/views/feed-create.html @@ -4,6 +4,6 @@ {% block content %}
      {% url "news:collection:rules" as cancel_url %} - {% include "components/form/form.html" with form=form title="Create rule" cancel_url=cancel_url confirm_text="Create rule" %} + {% include "components/form/form.html" with form=form title="Add a feed" cancel_url=cancel_url confirm_text="Add feed" %}
      {% endblock %} diff --git a/src/newsreader/news/collection/templates/news/collection/views/rule-update.html b/src/newsreader/news/collection/templates/news/collection/views/feed-update.html similarity index 72% rename from src/newsreader/news/collection/templates/news/collection/views/rule-update.html rename to src/newsreader/news/collection/templates/news/collection/views/feed-update.html index 0a705b8..33b1faf 100644 --- a/src/newsreader/news/collection/templates/news/collection/views/rule-update.html +++ b/src/newsreader/news/collection/templates/news/collection/views/feed-update.html @@ -3,12 +3,12 @@ {% block content %}
      - {% if rule.error %} + {% if feed.error %} {% trans "Failed to retrieve posts" as title %} - {% include "components/textbox/textbox.html" with title=title body=rule.error class="text-section--error" only %} + {% include "components/textbox/textbox.html" with title=title body=feed.error class="text-section--error" only %} {% endif %} {% url "news:collection:rules" as cancel_url %} - {% include "components/form/form.html" with form=form title="Update rule" cancel_url=cancel_url confirm_text="Save rule" only %} + {% include "components/form/form.html" with form=form title="Update feed" cancel_url=cancel_url confirm_text="Save feed" only %}
      {% endblock %} diff --git a/src/newsreader/news/collection/templates/news/collection/views/import.html b/src/newsreader/news/collection/templates/news/collection/views/import.html index df19887..9719847 100644 --- a/src/newsreader/news/collection/templates/news/collection/views/import.html +++ b/src/newsreader/news/collection/templates/news/collection/views/import.html @@ -4,6 +4,6 @@ {% block content %}
      {% url "news:collection:rules" as cancel_url %} - {% include "components/form/form.html" with form=form title="Import an OPML file" cancel_url=cancel_url confirm_text="Import rules" %} + {% include "components/form/form.html" with form=form title="Import an OPML file" cancel_url=cancel_url confirm_text="Import feeds" %}
      {% endblock %} diff --git a/src/newsreader/news/collection/templates/news/collection/views/rules.html b/src/newsreader/news/collection/templates/news/collection/views/rules.html index 0cd1870..678716e 100644 --- a/src/newsreader/news/collection/templates/news/collection/views/rules.html +++ b/src/newsreader/news/collection/templates/news/collection/views/rules.html @@ -14,8 +14,9 @@
      @@ -36,7 +37,7 @@
    • {% for rule in rules %} - + {% for rule in rules %} - + + + + + + - - - - - From e40d69d5ff5cfe6c513de53bd0328ffb5720a62b Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Sun, 11 May 2025 09:44:55 +0200 Subject: [PATCH 422/422] Use correct settings module for development --- Dockerfile | 4 ++++ docker-compose.development.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/Dockerfile b/Dockerfile index 116d20a..0ffa683 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,6 +66,8 @@ RUN --mount=type=cache,uid=$USER_ID,gid=$GROUP_ID,target=/home/newsreader/.cache COPY --chown=newsreader:newsreader ./src /app/src +ENV DJANGO_SETTINGS_MODULE=newsreader.conf.production + # Note that the static volume will have to be recreated to be pre-populated # correctly with the latest static files. See # https://docs.docker.com/storage/volumes/#populate-a-volume-using-a-container @@ -78,3 +80,5 @@ FROM backend AS development RUN --mount=type=cache,uid=$USER_ID,gid=$GROUP_ID,target=/home/newsreader/.cache/uv \ uv sync --frozen --group development + +ENV DJANGO_SETTINGS_MODULE=newsreader.conf.docker diff --git a/docker-compose.development.yml b/docker-compose.development.yml index 37236f6..9045200 100644 --- a/docker-compose.development.yml +++ b/docker-compose.development.yml @@ -6,6 +6,8 @@ services: build: &app-development-build target: development command: uv run --no-sync -- /app/src/manage.py runserver 0.0.0.0:8000 + environment: &django-env + DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-newsreader.conf.docker} ports: - "${DJANGO_PORT:-8000}:8000" volumes: @@ -17,6 +19,8 @@ services: celery: build: <<: *app-development-build + environment: + <<: *django-env volumes: - ./src/:/app/src
      @@ -32,24 +32,40 @@ {% trans "URL" %} {% trans "Successfuly ran" %} {% trans "Enabled" %}
      {% with rule|id_for_label:"rules" as id_for_label %} {% include "components/form/checkbox.html" with name="rules" value=rule.pk id=id_for_label id_for_label=id_for_label %} {% endwith %} {{ rule.name }}{{ rule.category.name }}{{ rule.url }}{{ rule.succeeded }}{{ rule.enabled }} + {{ rule.name }} + + {% if rule.category %} + {{ rule.category.name }} + {% endif %} + + {{ rule.url }} + - + {% if rule.succeeded %} + + {% else %} + + {% endif %} + + {% if rule.enabled %} + + {% else %} + + {% endif %}
      {% with rule|id_for_label:"rules" as id_for_label %} {% include "components/form/checkbox.html" with name="rules" value=rule.pk id=id_for_label id_for_label=id_for_label %} @@ -54,10 +55,10 @@ {{ rule.url }} - {% if rule.succeeded %} - - {% else %} + {% if rule.failed %} + {% else %} + {% endif %} diff --git a/src/newsreader/news/collection/templates/news/collection/views/twitter/timeline-create.html b/src/newsreader/news/collection/templates/news/collection/views/twitter/timeline-create.html new file mode 100644 index 0000000..7c8eb13 --- /dev/null +++ b/src/newsreader/news/collection/templates/news/collection/views/twitter/timeline-create.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
      + {% url "news:collection:rules" as cancel_url %} + {% include "components/form/form.html" with form=form title="Add a Twitter profile" cancel_url=cancel_url confirm_text="Add profile" %} +
      +{% endblock %} diff --git a/src/newsreader/news/collection/templates/news/collection/views/twitter/timeline-update.html b/src/newsreader/news/collection/templates/news/collection/views/twitter/timeline-update.html new file mode 100644 index 0000000..51de47a --- /dev/null +++ b/src/newsreader/news/collection/templates/news/collection/views/twitter/timeline-update.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% load static i18n %} + +{% block content %} +
      + {% if timeline.error %} + {% trans "Failed to retrieve posts" as title %} + {% include "components/textbox/textbox.html" with title=title body=timeline.error class="text-section--error" only %} + {% endif %} + + {% url "news:collection:rules" as cancel_url %} + {% include "components/form/form.html" with form=form title="Update profile" cancel_url=cancel_url confirm_text="Save profile" %} +
      +{% endblock %} diff --git a/src/newsreader/news/collection/tests/factories.py b/src/newsreader/news/collection/tests/factories.py index fdf786f..26f66cc 100644 --- a/src/newsreader/news/collection/tests/factories.py +++ b/src/newsreader/news/collection/tests/factories.py @@ -28,3 +28,8 @@ class FeedFactory(CollectionRuleFactory): class SubredditFactory(CollectionRuleFactory): type = RuleTypeChoices.subreddit website_url = REDDIT_URL + + +class TwitterTimelineFactory(CollectionRuleFactory): + type = RuleTypeChoices.twitter_timeline + screen_name = factory.Faker("user_name") diff --git a/src/newsreader/news/collection/tests/favicon/builder/tests.py b/src/newsreader/news/collection/tests/favicon/builder/tests.py index e8a1a34..d21f77e 100644 --- a/src/newsreader/news/collection/tests/favicon/builder/tests.py +++ b/src/newsreader/news/collection/tests/favicon/builder/tests.py @@ -1,3 +1,5 @@ +from unittest.mock import Mock + from django.test import TestCase from newsreader.news.collection.favicon import FaviconBuilder @@ -12,8 +14,11 @@ class FaviconBuilderTestCase(TestCase): def test_simple(self): rule = CollectionRuleFactory(favicon=None) - with FaviconBuilder((rule, simple_mock)) as builder: + with FaviconBuilder(simple_mock, Mock(rule=rule)) as builder: builder.build() + builder.save() + + rule.refresh_from_db() self.assertEquals(rule.favicon, "https://www.bbc.com/favicon.ico") @@ -22,24 +27,33 @@ class FaviconBuilderTestCase(TestCase): website_url="https://www.theguardian.com/", favicon=None ) - with FaviconBuilder((rule, mock_without_url)) as builder: + with FaviconBuilder(mock_without_url, Mock(rule=rule)) as builder: builder.build() + builder.save() + + rule.refresh_from_db() self.assertEquals(rule.favicon, "https://www.theguardian.com/favicon.ico") def test_without_header(self): rule = CollectionRuleFactory(favicon=None) - with FaviconBuilder((rule, mock_without_header)) as builder: + with FaviconBuilder(mock_without_header, Mock(rule=rule)) as builder: builder.build() + builder.save() + + rule.refresh_from_db() self.assertEquals(rule.favicon, None) def test_weird_path(self): rule = CollectionRuleFactory(favicon=None) - with FaviconBuilder((rule, mock_with_weird_path)) as builder: + with FaviconBuilder(mock_with_weird_path, Mock(rule=rule)) as builder: builder.build() + builder.save() + + rule.refresh_from_db() self.assertEquals( rule.favicon, "https://www.theguardian.com/jabadaba/doe/favicon.ico" @@ -48,15 +62,21 @@ class FaviconBuilderTestCase(TestCase): def test_other_url(self): rule = CollectionRuleFactory(favicon=None) - with FaviconBuilder((rule, mock_with_other_url)) as builder: + with FaviconBuilder(mock_with_other_url, Mock(rule=rule)) as builder: builder.build() + builder.save() + + rule.refresh_from_db() self.assertEquals(rule.favicon, "https://www.theguardian.com/icon.png") def test_url_with_favicon_takes_precedence(self): rule = CollectionRuleFactory(favicon=None) - with FaviconBuilder((rule, mock_with_multiple_icons)) as builder: + with FaviconBuilder(mock_with_multiple_icons, Mock(rule=rule)) as builder: builder.build() + builder.save() + + rule.refresh_from_db() self.assertEquals(rule.favicon, "https://www.bbc.com/favicon.ico") diff --git a/src/newsreader/news/collection/tests/favicon/client/tests.py b/src/newsreader/news/collection/tests/favicon/client/tests.py index 717ee0c..85b8fa3 100644 --- a/src/newsreader/news/collection/tests/favicon/client/tests.py +++ b/src/newsreader/news/collection/tests/favicon/client/tests.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock +from unittest.mock import Mock from django.test import TestCase @@ -19,22 +19,22 @@ class FaviconClientTestCase(TestCase): def test_simple(self): rule = CollectionRuleFactory() - stream = MagicMock(url="https://www.bbc.com") + stream = Mock(url="https://www.bbc.com", rule=rule) stream.read.return_value = (simple_mock, stream) - with FaviconClient([(rule, stream)]) as client: - for rule, data in client: - self.assertEquals(rule.pk, rule.pk) - self.assertEquals(data, simple_mock) + with FaviconClient([stream]) as client: + for payload, stream in client: + self.assertEquals(stream.rule.pk, rule.pk) + self.assertEquals(payload, simple_mock) stream.read.assert_called_once_with() def test_client_catches_stream_exception(self): rule = CollectionRuleFactory(error=None, succeeded=True) - stream = MagicMock(url="https://www.bbc.com") + stream = Mock(url="https://www.bbc.com", rule=rule) stream.read.side_effect = StreamException - with FaviconClient([(rule, stream)]) as client: + with FaviconClient([stream]) as client: for rule, data in client: pass @@ -46,10 +46,10 @@ class FaviconClientTestCase(TestCase): def test_client_catches_stream_not_found_exception(self): rule = CollectionRuleFactory(error=None, succeeded=True) - stream = MagicMock(url="https://www.bbc.com") + stream = Mock(url="https://www.bbc.com", rule=rule) stream.read.side_effect = StreamNotFoundException - with FaviconClient([(rule, stream)]) as client: + with FaviconClient([stream]) as client: for rule, data in client: pass @@ -61,10 +61,10 @@ class FaviconClientTestCase(TestCase): def test_client_catches_stream_denied_exception(self): rule = CollectionRuleFactory(error=None, succeeded=True) - stream = MagicMock(url="https://www.bbc.com") + stream = Mock(url="https://www.bbc.com", rule=rule) stream.read.side_effect = StreamDeniedException - with FaviconClient([(rule, stream)]) as client: + with FaviconClient([stream]) as client: for rule, data in client: pass @@ -76,10 +76,10 @@ class FaviconClientTestCase(TestCase): def test_client_catches_stream_timed_out(self): rule = CollectionRuleFactory(error=None, succeeded=True) - stream = MagicMock(url="https://www.bbc.com") + stream = Mock(url="https://www.bbc.com", rule=rule) stream.read.side_effect = StreamTimeOutException - with FaviconClient([(rule, stream)]) as client: + with FaviconClient([stream]) as client: for rule, data in client: pass diff --git a/src/newsreader/news/collection/tests/favicon/collector/tests.py b/src/newsreader/news/collection/tests/favicon/collector/tests.py index 44254a5..cb73a7c 100644 --- a/src/newsreader/news/collection/tests/favicon/collector/tests.py +++ b/src/newsreader/news/collection/tests/favicon/collector/tests.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import Mock, patch from django.test import TestCase @@ -38,8 +38,8 @@ class FaviconCollectorTestCase(TestCase): def test_simple(self): rule = CollectionRuleFactory(succeeded=True, error=None) - self.mocked_feed_client.return_value = [(feed_mock, MagicMock(rule=rule))] - self.mocked_website_read.return_value = (website_mock, MagicMock()) + self.mocked_feed_client.return_value = [(feed_mock, Mock(rule=rule))] + self.mocked_website_read.return_value = (website_mock, Mock(rule=rule)) collector = FaviconCollector() collector.collect() @@ -54,8 +54,11 @@ class FaviconCollectorTestCase(TestCase): def test_empty_stream(self): rule = CollectionRuleFactory(succeeded=True, error=None) - self.mocked_feed_client.return_value = [(feed_mock, MagicMock(rule=rule))] - self.mocked_website_read.return_value = (BeautifulSoup("", "lxml"), MagicMock()) + self.mocked_feed_client.return_value = [(feed_mock, Mock(rule=rule))] + self.mocked_website_read.return_value = ( + BeautifulSoup("", "lxml"), + Mock(rule=rule), + ) collector = FaviconCollector() collector.collect() @@ -70,7 +73,7 @@ class FaviconCollectorTestCase(TestCase): def test_not_found(self): rule = CollectionRuleFactory(succeeded=True, error=None) - self.mocked_feed_client.return_value = [(feed_mock, MagicMock(rule=rule))] + self.mocked_feed_client.return_value = [(feed_mock, Mock(rule=rule))] self.mocked_website_read.side_effect = StreamNotFoundException collector = FaviconCollector() @@ -86,7 +89,7 @@ class FaviconCollectorTestCase(TestCase): def test_denied(self): rule = CollectionRuleFactory(succeeded=True, error=None) - self.mocked_feed_client.return_value = [(feed_mock, MagicMock(rule=rule))] + self.mocked_feed_client.return_value = [(feed_mock, Mock(rule=rule))] self.mocked_website_read.side_effect = StreamDeniedException collector = FaviconCollector() @@ -102,7 +105,7 @@ class FaviconCollectorTestCase(TestCase): def test_forbidden(self): rule = CollectionRuleFactory(succeeded=True, error=None) - self.mocked_feed_client.return_value = [(feed_mock, MagicMock(rule=rule))] + self.mocked_feed_client.return_value = [(feed_mock, Mock(rule=rule))] self.mocked_website_read.side_effect = StreamForbiddenException collector = FaviconCollector() @@ -118,7 +121,7 @@ class FaviconCollectorTestCase(TestCase): def test_timed_out(self): rule = CollectionRuleFactory(succeeded=True, error=None) - self.mocked_feed_client.return_value = [(feed_mock, MagicMock(rule=rule))] + self.mocked_feed_client.return_value = [(feed_mock, Mock(rule=rule))] self.mocked_website_read.side_effect = StreamTimeOutException collector = FaviconCollector() @@ -134,7 +137,7 @@ class FaviconCollectorTestCase(TestCase): def test_wrong_stream_content_type(self): rule = CollectionRuleFactory(succeeded=True, error=None) - self.mocked_feed_client.return_value = [(feed_mock, MagicMock(rule=rule))] + self.mocked_feed_client.return_value = [(feed_mock, Mock(rule=rule))] self.mocked_website_read.side_effect = StreamParseException collector = FaviconCollector() diff --git a/src/newsreader/news/collection/tests/feed/builder/tests.py b/src/newsreader/news/collection/tests/feed/builder/tests.py index 4a6eb69..571a7cd 100644 --- a/src/newsreader/news/collection/tests/feed/builder/tests.py +++ b/src/newsreader/news/collection/tests/feed/builder/tests.py @@ -1,5 +1,5 @@ from datetime import date, datetime, time -from unittest.mock import MagicMock +from unittest.mock import Mock from django.test import TestCase from django.utils import timezone @@ -24,9 +24,10 @@ class FeedBuilderTestCase(TestCase): def test_basic_entry(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((simple_mock, mock_stream)) as builder: + with builder(simple_mock, mock_stream) as builder: + builder.build() builder.save() post = Post.objects.get() @@ -55,9 +56,10 @@ class FeedBuilderTestCase(TestCase): def test_multiple_entries(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((multiple_mock, mock_stream)) as builder: + with builder(multiple_mock, mock_stream) as builder: + builder.build() builder.save() posts = Post.objects.order_by("-publication_date") @@ -116,9 +118,10 @@ class FeedBuilderTestCase(TestCase): def test_entries_without_remote_identifier(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_without_identifier, mock_stream)) as builder: + with builder(mock_without_identifier, mock_stream) as builder: + builder.build() builder.save() posts = Post.objects.order_by("-publication_date") @@ -155,9 +158,10 @@ class FeedBuilderTestCase(TestCase): def test_entry_without_publication_date(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_without_publish_date, mock_stream)) as builder: + with builder(mock_without_publish_date, mock_stream) as builder: + builder.build() builder.save() posts = Post.objects.order_by("-publication_date") @@ -187,9 +191,10 @@ class FeedBuilderTestCase(TestCase): def test_entry_without_url(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_without_url, mock_stream)) as builder: + with builder(mock_without_url, mock_stream) as builder: + builder.build() builder.save() posts = Post.objects.order_by("-publication_date") @@ -213,9 +218,10 @@ class FeedBuilderTestCase(TestCase): def test_entry_without_body(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_without_body, mock_stream)) as builder: + with builder(mock_without_body, mock_stream) as builder: + builder.build() builder.save() posts = Post.objects.order_by("-publication_date") @@ -247,9 +253,10 @@ class FeedBuilderTestCase(TestCase): def test_entry_without_author(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_without_author, mock_stream)) as builder: + with builder(mock_without_author, mock_stream) as builder: + builder.build() builder.save() posts = Post.objects.order_by("-publication_date") @@ -275,9 +282,10 @@ class FeedBuilderTestCase(TestCase): def test_empty_entries(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_without_entries, mock_stream)) as builder: + with builder(mock_without_entries, mock_stream) as builder: + builder.build() builder.save() self.assertEquals(Post.objects.count(), 0) @@ -285,7 +293,7 @@ class FeedBuilderTestCase(TestCase): def test_update_entries(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) existing_first_post = FeedPostFactory.create( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", rule=rule @@ -295,7 +303,8 @@ class FeedBuilderTestCase(TestCase): remote_identifier="a5479c66-8fae-11e9-8422-00163ef6bee7", rule=rule ) - with builder((mock_with_update_entries, mock_stream)) as builder: + with builder(mock_with_update_entries, mock_stream) as builder: + builder.build() builder.save() self.assertEquals(Post.objects.count(), 3) @@ -315,9 +324,10 @@ class FeedBuilderTestCase(TestCase): def test_html_sanitizing(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_with_html, mock_stream)) as builder: + with builder(mock_with_html, mock_stream) as builder: + builder.build() builder.save() post = Post.objects.get() @@ -337,9 +347,10 @@ class FeedBuilderTestCase(TestCase): def test_long_author_text_is_truncated(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_with_long_author, mock_stream)) as builder: + with builder(mock_with_long_author, mock_stream) as builder: + builder.build() builder.save() post = Post.objects.get() @@ -351,9 +362,10 @@ class FeedBuilderTestCase(TestCase): def test_long_title_text_is_truncated(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_with_long_title, mock_stream)) as builder: + with builder(mock_with_long_title, mock_stream) as builder: + builder.build() builder.save() post = Post.objects.get() @@ -366,9 +378,10 @@ class FeedBuilderTestCase(TestCase): def test_long_title_exotic_title(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_with_long_exotic_title, mock_stream)) as builder: + with builder(mock_with_long_exotic_title, mock_stream) as builder: + builder.build() builder.save() post = Post.objects.get() @@ -381,9 +394,10 @@ class FeedBuilderTestCase(TestCase): def test_content_detail_is_prioritized_if_longer(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_with_longer_content_detail, mock_stream)) as builder: + with builder(mock_with_longer_content_detail, mock_stream) as builder: + builder.build() builder.save() post = Post.objects.get() @@ -398,9 +412,10 @@ class FeedBuilderTestCase(TestCase): def test_content_detail_is_not_prioritized_if_shorter(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_with_shorter_content_detail, mock_stream)) as builder: + with builder(mock_with_shorter_content_detail, mock_stream) as builder: + builder.build() builder.save() post = Post.objects.get() @@ -414,9 +429,10 @@ class FeedBuilderTestCase(TestCase): def test_content_detail_is_concatinated(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_with_multiple_content_detail, mock_stream)) as builder: + with builder(mock_with_multiple_content_detail, mock_stream) as builder: + builder.build() builder.save() post = Post.objects.get() diff --git a/src/newsreader/news/collection/tests/feed/client/tests.py b/src/newsreader/news/collection/tests/feed/client/tests.py index 24eb214..9a2365e 100644 --- a/src/newsreader/news/collection/tests/feed/client/tests.py +++ b/src/newsreader/news/collection/tests/feed/client/tests.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import Mock, patch from django.test import TestCase from django.utils.lorem_ipsum import words @@ -28,7 +28,7 @@ class FeedClientTestCase(TestCase): def test_client_retrieves_single_rules(self): rule = FeedFactory.create() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) self.mocked_read.return_value = (simple_mock, mock_stream) diff --git a/src/newsreader/news/collection/tests/feed/collector/tests.py b/src/newsreader/news/collection/tests/feed/collector/tests.py index 5a1bac1..a7f3573 100644 --- a/src/newsreader/news/collection/tests/feed/collector/tests.py +++ b/src/newsreader/news/collection/tests/feed/collector/tests.py @@ -1,6 +1,6 @@ from datetime import date, datetime, time from time import struct_time -from unittest.mock import MagicMock, patch +from unittest.mock import Mock, patch from django.test import TestCase from django.utils import timezone @@ -26,6 +26,7 @@ from newsreader.news.core.tests.factories import FeedPostFactory from .mocks import duplicate_mock, empty_mock, multiple_mock, multiple_update_mock +@freeze_time("2019-10-30 12:30:00") class FeedCollectorTestCase(TestCase): def setUp(self): self.maxDiff = None @@ -39,43 +40,42 @@ class FeedCollectorTestCase(TestCase): def tearDown(self): patch.stopall() - @freeze_time("2019-10-30 12:30:00") def test_simple_batch(self): self.mocked_parse.return_value = multiple_mock - rule = FeedFactory() + rule = FeedFactory() collector = FeedCollector() - collector.collect() + collector.collect(rules=[rule]) rule.refresh_from_db() self.assertEquals(Post.objects.count(), 3) self.assertEquals(rule.succeeded, True) - self.assertEquals(rule.last_suceeded, timezone.now()) + self.assertEquals(rule.last_run, timezone.now()) self.assertEquals(rule.error, None) - @freeze_time("2019-10-30 12:30:00") def test_emtpy_batch(self): - self.mocked_fetch.return_value = MagicMock() + self.mocked_fetch.return_value = Mock() self.mocked_parse.return_value = empty_mock + rule = FeedFactory() collector = FeedCollector() - collector.collect() + collector.collect(rules=[rule]) rule.refresh_from_db() self.assertEquals(Post.objects.count(), 0) self.assertEquals(rule.succeeded, True) self.assertEquals(rule.error, None) - self.assertEquals(rule.last_suceeded, timezone.now()) + self.assertEquals(rule.last_run, timezone.now()) def test_not_found(self): self.mocked_fetch.side_effect = StreamNotFoundException - rule = FeedFactory() + rule = FeedFactory() collector = FeedCollector() - collector.collect() + collector.collect(rules=[rule]) rule.refresh_from_db() @@ -85,58 +85,59 @@ class FeedCollectorTestCase(TestCase): def test_denied(self): self.mocked_fetch.side_effect = StreamDeniedException - last_suceeded = timezone.make_aware( - datetime.combine(date=date(2019, 10, 30), time=time(12, 30)) - ) - rule = FeedFactory(last_suceeded=last_suceeded) + + old_run = timezone.make_aware(datetime(2019, 10, 30, 12, 30)) + rule = FeedFactory(last_run=old_run) collector = FeedCollector() - collector.collect() + collector.collect(rules=[rule]) rule.refresh_from_db() self.assertEquals(Post.objects.count(), 0) self.assertEquals(rule.succeeded, False) self.assertEquals(rule.error, "Stream does not have sufficient permissions") - self.assertEquals(rule.last_suceeded, last_suceeded) + self.assertEquals(rule.last_run, timezone.now()) def test_forbidden(self): self.mocked_fetch.side_effect = StreamForbiddenException - last_suceeded = timezone.make_aware( - datetime.combine(date=date(2019, 10, 30), time=time(12, 30)) - ) - rule = FeedFactory(last_suceeded=last_suceeded) + + old_run = pytz.utc.localize(datetime(2019, 10, 30, 12, 30)) + rule = FeedFactory(last_run=old_run) collector = FeedCollector() - collector.collect() + collector.collect(rules=[rule]) rule.refresh_from_db() self.assertEquals(Post.objects.count(), 0) self.assertEquals(rule.succeeded, False) self.assertEquals(rule.error, "Stream forbidden") - self.assertEquals(rule.last_suceeded, last_suceeded) + self.assertEquals(rule.last_run, timezone.now()) def test_timed_out(self): self.mocked_fetch.side_effect = StreamTimeOutException - last_suceeded = timezone.make_aware( + + last_run = timezone.make_aware( datetime.combine(date=date(2019, 10, 30), time=time(12, 30)) ) - rule = FeedFactory(last_suceeded=last_suceeded) + rule = FeedFactory(last_run=last_run) collector = FeedCollector() - collector.collect() + collector.collect(rules=[rule]) rule.refresh_from_db() self.assertEquals(Post.objects.count(), 0) self.assertEquals(rule.succeeded, False) self.assertEquals(rule.error, "Stream timed out") - self.assertEquals(rule.last_suceeded, last_suceeded) + self.assertEquals( + rule.last_run, pytz.utc.localize(datetime(2019, 10, 30, 12, 30)) + ) - @freeze_time("2019-10-30 12:30:00") def test_duplicates(self): self.mocked_parse.return_value = duplicate_mock + rule = FeedFactory() aware_datetime = build_publication_date( @@ -186,10 +187,9 @@ class FeedCollectorTestCase(TestCase): self.assertEquals(Post.objects.count(), 3) self.assertEquals(rule.succeeded, True) - self.assertEquals(rule.last_suceeded, timezone.now()) + self.assertEquals(rule.last_run, timezone.now()) self.assertEquals(rule.error, None) - @freeze_time("2019-02-22 12:30:00") def test_items_with_identifiers_get_updated(self): self.mocked_parse.return_value = multiple_update_mock rule = FeedFactory() @@ -231,7 +231,7 @@ class FeedCollectorTestCase(TestCase): self.assertEquals(Post.objects.count(), 3) self.assertEquals(rule.succeeded, True) - self.assertEquals(rule.last_suceeded, timezone.now()) + self.assertEquals(rule.last_run, timezone.now()) self.assertEquals(rule.error, None) self.assertEquals( @@ -245,23 +245,3 @@ class FeedCollectorTestCase(TestCase): self.assertEquals( third_post.title, "Birmingham head teacher threatened over LGBT lessons" ) - - @freeze_time("2019-02-22 12:30:00") - def test_disabled_rules(self): - rules = (FeedFactory(enabled=False), FeedFactory(enabled=True)) - - self.mocked_parse.return_value = multiple_mock - - collector = FeedCollector() - collector.collect() - - for rule in rules: - rule.refresh_from_db() - - self.assertEquals(Post.objects.count(), 3) - self.assertEquals(rules[1].succeeded, True) - self.assertEquals(rules[1].last_suceeded, timezone.now()) - self.assertEquals(rules[1].error, None) - - self.assertEquals(rules[0].last_suceeded, None) - self.assertEquals(rules[0].succeeded, False) diff --git a/src/newsreader/news/collection/tests/feed/stream/tests.py b/src/newsreader/news/collection/tests/feed/stream/tests.py index 82a09a3..f827c15 100644 --- a/src/newsreader/news/collection/tests/feed/stream/tests.py +++ b/src/newsreader/news/collection/tests/feed/stream/tests.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import Mock, patch from django.test import TestCase @@ -27,7 +27,7 @@ class FeedStreamTestCase(TestCase): patch.stopall() def test_simple_stream(self): - self.mocked_fetch.return_value = MagicMock(content=simple_mock) + self.mocked_fetch.return_value = Mock(content=simple_mock) rule = FeedFactory() stream = FeedStream(rule) @@ -95,7 +95,7 @@ class FeedStreamTestCase(TestCase): @patch("newsreader.news.collection.feed.parse") def test_stream_raises_parse_exception(self, mocked_parse): - self.mocked_fetch.return_value = MagicMock() + self.mocked_fetch.return_value = Mock() mocked_parse.side_effect = TypeError rule = FeedFactory() diff --git a/src/newsreader/news/collection/tests/reddit/builder/tests.py b/src/newsreader/news/collection/tests/reddit/builder/tests.py index 9c1a046..11cf549 100644 --- a/src/newsreader/news/collection/tests/reddit/builder/tests.py +++ b/src/newsreader/news/collection/tests/reddit/builder/tests.py @@ -1,5 +1,5 @@ from datetime import datetime -from unittest.mock import MagicMock +from unittest.mock import Mock from django.test import TestCase @@ -20,9 +20,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((simple_mock, mock_stream)) as builder: + with builder(simple_mock, mock_stream) as builder: + builder.build() builder.save() posts = {post.remote_identifier: post for post in Post.objects.all()} @@ -65,9 +66,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((empty_mock, mock_stream)) as builder: + with builder(empty_mock, mock_stream) as builder: + builder.build() builder.save() self.assertEquals(Post.objects.count(), 0) @@ -76,9 +78,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((unknown_mock, mock_stream)) as builder: + with builder(unknown_mock, mock_stream) as builder: + builder.build() builder.save() self.assertEquals(Post.objects.count(), 0) @@ -95,9 +98,10 @@ class RedditBuilderTestCase(TestCase): ) builder = RedditBuilder - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((simple_mock, mock_stream)) as builder: + with builder(simple_mock, mock_stream) as builder: + builder.build() builder.save() posts = {post.remote_identifier: post for post in Post.objects.all()} @@ -132,9 +136,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((unsanitized_mock, mock_stream)) as builder: + with builder(unsanitized_mock, mock_stream) as builder: + builder.build() builder.save() posts = {post.remote_identifier: post for post in Post.objects.all()} @@ -149,9 +154,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((author_mock, mock_stream)) as builder: + with builder(author_mock, mock_stream) as builder: + builder.build() builder.save() posts = {post.remote_identifier: post for post in Post.objects.all()} @@ -166,9 +172,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((title_mock, mock_stream)) as builder: + with builder(title_mock, mock_stream) as builder: + builder.build() builder.save() posts = {post.remote_identifier: post for post in Post.objects.all()} @@ -186,9 +193,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((duplicate_mock, mock_stream)) as builder: + with builder(duplicate_mock, mock_stream) as builder: + builder.build() builder.save() posts = {post.remote_identifier: post for post in Post.objects.all()} @@ -200,13 +208,14 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) duplicate_post = RedditPostFactory( remote_identifier="hm0qct", rule=subreddit, title="foo" ) - with builder((simple_mock, mock_stream)) as builder: + with builder(simple_mock, mock_stream) as builder: + builder.build() builder.save() posts = {post.remote_identifier: post for post in Post.objects.all()} @@ -231,9 +240,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((image_mock, mock_stream)) as builder: + with builder(image_mock, mock_stream) as builder: + builder.build() builder.save() posts = {post.remote_identifier: post for post in Post.objects.all()} @@ -262,9 +272,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((external_image_mock, mock_stream)) as builder: + with builder(external_image_mock, mock_stream) as builder: + builder.build() builder.save() posts = {post.remote_identifier: post for post in Post.objects.all()} @@ -302,9 +313,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((video_mock, mock_stream)) as builder: + with builder(video_mock, mock_stream) as builder: + builder.build() builder.save() posts = {post.remote_identifier: post for post in Post.objects.all()} @@ -328,9 +340,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((external_video_mock, mock_stream)) as builder: + with builder(external_video_mock, mock_stream) as builder: + builder.build() builder.save() post = Post.objects.get() @@ -354,9 +367,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((external_gifv_mock, mock_stream)) as builder: + with builder(external_gifv_mock, mock_stream) as builder: + builder.build() builder.save() post = Post.objects.get() @@ -376,9 +390,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((simple_mock, mock_stream)) as builder: + with builder(simple_mock, mock_stream) as builder: + builder.build() builder.save() post = Post.objects.get(remote_identifier="hngsj8") @@ -400,9 +415,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((unknown_mock, mock_stream)) as builder: + with builder(unknown_mock, mock_stream) as builder: + builder.build() builder.save() self.assertEquals(Post.objects.count(), 0) diff --git a/src/newsreader/news/collection/tests/reddit/client/tests.py b/src/newsreader/news/collection/tests/reddit/client/tests.py index f2ee84d..4dcc10f 100644 --- a/src/newsreader/news/collection/tests/reddit/client/tests.py +++ b/src/newsreader/news/collection/tests/reddit/client/tests.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import Mock, patch from uuid import uuid4 from django.test import TestCase @@ -31,7 +31,7 @@ class RedditClientTestCase(TestCase): def test_client_retrieves_single_rules(self): subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) self.mocked_read.return_value = (simple_mock, mock_stream) @@ -150,7 +150,7 @@ class RedditClientTestCase(TestCase): def test_client_catches_long_exception_text(self): subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) self.mocked_read.side_effect = StreamParseException(message=words(1000)) diff --git a/src/newsreader/news/collection/tests/reddit/collector/tests.py b/src/newsreader/news/collection/tests/reddit/collector/tests.py index 1fd18b0..fa2f5d4 100644 --- a/src/newsreader/news/collection/tests/reddit/collector/tests.py +++ b/src/newsreader/news/collection/tests/reddit/collector/tests.py @@ -74,7 +74,7 @@ class RedditCollectorTestCase(TestCase): for subreddit in rules: with self.subTest(subreddit=subreddit): self.assertEquals(subreddit.succeeded, True) - self.assertEquals(subreddit.last_suceeded, timezone.now()) + self.assertEquals(subreddit.last_run, timezone.now()) self.assertEquals(subreddit.error, None) post = Post.objects.get( @@ -133,7 +133,7 @@ class RedditCollectorTestCase(TestCase): for subreddit in rules: with self.subTest(subreddit=subreddit): self.assertEquals(subreddit.succeeded, True) - self.assertEquals(subreddit.last_suceeded, timezone.now()) + self.assertEquals(subreddit.last_run, timezone.now()) self.assertEquals(subreddit.error, None) def test_not_found(self): diff --git a/src/newsreader/news/collection/tests/reddit/test_scheduler.py b/src/newsreader/news/collection/tests/reddit/test_scheduler.py index cd062b6..0f04d53 100644 --- a/src/newsreader/news/collection/tests/reddit/test_scheduler.py +++ b/src/newsreader/news/collection/tests/reddit/test_scheduler.py @@ -25,19 +25,19 @@ class RedditSchedulerTestCase(TestCase): CollectionRuleFactory( user=user_1, type=RuleTypeChoices.subreddit, - last_suceeded=timezone.now() - timedelta(days=4), + last_run=timezone.now() - timedelta(days=4), enabled=True, ), CollectionRuleFactory( user=user_1, type=RuleTypeChoices.subreddit, - last_suceeded=timezone.now() - timedelta(days=3), + last_run=timezone.now() - timedelta(days=3), enabled=True, ), CollectionRuleFactory( user=user_1, type=RuleTypeChoices.subreddit, - last_suceeded=timezone.now() - timedelta(days=2), + last_run=timezone.now() - timedelta(days=2), enabled=True, ), ] @@ -46,19 +46,19 @@ class RedditSchedulerTestCase(TestCase): CollectionRuleFactory( user=user_2, type=RuleTypeChoices.subreddit, - last_suceeded=timezone.now() - timedelta(days=4), + last_run=timezone.now() - timedelta(days=4), enabled=True, ), CollectionRuleFactory( user=user_2, type=RuleTypeChoices.subreddit, - last_suceeded=timezone.now() - timedelta(days=3), + last_run=timezone.now() - timedelta(days=3), enabled=True, ), CollectionRuleFactory( user=user_2, type=RuleTypeChoices.subreddit, - last_suceeded=timezone.now() - timedelta(days=2), + last_run=timezone.now() - timedelta(days=2), enabled=True, ), ] @@ -87,7 +87,7 @@ class RedditSchedulerTestCase(TestCase): CollectionRuleFactory.create_batch( name=f"rule-{index}", type=RuleTypeChoices.subreddit, - last_suceeded=timezone.now() - timedelta(seconds=index), + last_run=timezone.now() - timedelta(seconds=index), enabled=True, user=user, size=15, @@ -121,7 +121,7 @@ class RedditSchedulerTestCase(TestCase): CollectionRuleFactory( name=f"rule-{index}", type=RuleTypeChoices.subreddit, - last_suceeded=timezone.now() - timedelta(seconds=index), + last_run=timezone.now() - timedelta(seconds=index), enabled=True, user=user, ) diff --git a/src/newsreader/news/collection/tests/tests.py b/src/newsreader/news/collection/tests/tests.py index 363e0b5..c7f0bb0 100644 --- a/src/newsreader/news/collection/tests/tests.py +++ b/src/newsreader/news/collection/tests/tests.py @@ -1,10 +1,9 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import Mock, patch from django.test import TestCase from bs4 import BeautifulSoup -from newsreader.news.collection.base import URLBuilder, WebsiteStream from newsreader.news.collection.exceptions import ( StreamDeniedException, StreamException, @@ -13,6 +12,7 @@ from newsreader.news.collection.exceptions import ( StreamParseException, StreamTimeOutException, ) +from newsreader.news.collection.favicon import WebsiteStream, WebsiteURLBuilder from newsreader.news.collection.tests.factories import CollectionRuleFactory from .mocks import feed_mock_without_link, simple_feed_mock, simple_mock @@ -20,117 +20,125 @@ from .mocks import feed_mock_without_link, simple_feed_mock, simple_mock class WebsiteStreamTestCase(TestCase): def setUp(self): - self.patched_fetch = patch("newsreader.news.collection.base.fetch") + self.patched_fetch = patch("newsreader.news.collection.favicon.fetch") self.mocked_fetch = self.patched_fetch.start() def tearDown(self): patch.stopall() def test_simple(self): - self.mocked_fetch.return_value = MagicMock(content=simple_mock) + self.mocked_fetch.return_value = Mock(content=simple_mock) - rule = CollectionRuleFactory() - stream = WebsiteStream(rule.url) + rule = CollectionRuleFactory(website_url="https://www.bbc.co.uk/news/") + stream = WebsiteStream(rule) return_value = stream.read() - self.mocked_fetch.assert_called_once_with(rule.url) - self.assertEquals(return_value, (BeautifulSoup(simple_mock, "lxml"), stream)) + self.mocked_fetch.assert_called_once_with("https://www.bbc.co.uk/news/") + self.assertEquals( + return_value, (BeautifulSoup(simple_mock, features="lxml"), stream) + ) def test_raises_exception(self): self.mocked_fetch.side_effect = StreamException - rule = CollectionRuleFactory() - stream = WebsiteStream(rule.url) + rule = CollectionRuleFactory(website_url="https://www.bbc.co.uk/news/") + stream = WebsiteStream(rule) with self.assertRaises(StreamException): stream.read() - self.mocked_fetch.assert_called_once_with(rule.url) + self.mocked_fetch.assert_called_once_with("https://www.bbc.co.uk/news/") def test_raises_denied_exception(self): self.mocked_fetch.side_effect = StreamDeniedException - rule = CollectionRuleFactory() - stream = WebsiteStream(rule.url) + rule = CollectionRuleFactory(website_url="https://www.bbc.co.uk/news/") + stream = WebsiteStream(rule) with self.assertRaises(StreamDeniedException): stream.read() - self.mocked_fetch.assert_called_once_with(rule.url) + self.mocked_fetch.assert_called_once_with("https://www.bbc.co.uk/news/") def test_raises_stream_not_found_exception(self): self.mocked_fetch.side_effect = StreamNotFoundException - rule = CollectionRuleFactory() - stream = WebsiteStream(rule.url) + rule = CollectionRuleFactory(website_url="https://www.bbc.co.uk/news/") + stream = WebsiteStream(rule) with self.assertRaises(StreamNotFoundException): stream.read() - self.mocked_fetch.assert_called_once_with(rule.url) + self.mocked_fetch.assert_called_once_with("https://www.bbc.co.uk/news/") def test_stream_raises_time_out_exception(self): self.mocked_fetch.side_effect = StreamTimeOutException - rule = CollectionRuleFactory() - stream = WebsiteStream(rule.url) + rule = CollectionRuleFactory(website_url="https://www.bbc.co.uk/news/") + stream = WebsiteStream(rule) with self.assertRaises(StreamTimeOutException): stream.read() - self.mocked_fetch.assert_called_once_with(rule.url) + self.mocked_fetch.assert_called_once_with("https://www.bbc.co.uk/news/") def test_stream_raises_forbidden_exception(self): self.mocked_fetch.side_effect = StreamForbiddenException - rule = CollectionRuleFactory() - stream = WebsiteStream(rule.url) + rule = CollectionRuleFactory(website_url="https://www.bbc.co.uk/news/") + stream = WebsiteStream(rule) with self.assertRaises(StreamForbiddenException): stream.read() - self.mocked_fetch.assert_called_once_with(rule.url) + self.mocked_fetch.assert_called_once_with("https://www.bbc.co.uk/news/") - @patch("newsreader.news.collection.base.WebsiteStream.parse") + @patch("newsreader.news.collection.favicon.WebsiteStream.parse") def test_stream_raises_parse_exception(self, mocked_parse): - self.mocked_fetch.return_value = MagicMock() + self.mocked_fetch.return_value = Mock() mocked_parse.side_effect = StreamParseException - rule = CollectionRuleFactory() - stream = WebsiteStream(rule.url) + rule = CollectionRuleFactory(website_url="https://www.bbc.co.uk/news/") + stream = WebsiteStream(rule) with self.assertRaises(StreamParseException): stream.read() - self.mocked_fetch.assert_called_once_with(rule.url) + self.mocked_fetch.assert_called_once_with("https://www.bbc.co.uk/news/") -class URLBuilderTestCase(TestCase): +class WebsiteURLBuilderTestCase(TestCase): def test_simple(self): initial_rule = CollectionRuleFactory() - with URLBuilder((simple_feed_mock, MagicMock(rule=initial_rule))) as builder: - rule, url = builder.build() + with WebsiteURLBuilder(simple_feed_mock, Mock(rule=initial_rule)) as builder: + builder.build() + builder.save() - self.assertEquals(rule.pk, initial_rule.pk) - self.assertEquals(url, "https://www.bbc.co.uk/news/") + initial_rule.refresh_from_db() + + self.assertEquals(initial_rule.website_url, "https://www.bbc.co.uk/news/") def test_no_link(self): - initial_rule = CollectionRuleFactory() + initial_rule = CollectionRuleFactory(website_url=None) - with URLBuilder( - (feed_mock_without_link, MagicMock(rule=initial_rule)) + with WebsiteURLBuilder( + feed_mock_without_link, Mock(rule=initial_rule) ) as builder: - rule, url = builder.build() + builder.build() + builder.save() - self.assertEquals(rule.pk, initial_rule.pk) - self.assertEquals(url, None) + initial_rule.refresh_from_db() + + self.assertEquals(initial_rule.website_url, None) def test_no_data(self): - initial_rule = CollectionRuleFactory() + initial_rule = CollectionRuleFactory(website_url=None) - with URLBuilder((None, MagicMock(rule=initial_rule))) as builder: - rule, url = builder.build() + with WebsiteURLBuilder(None, Mock(rule=initial_rule)) as builder: + builder.build() + builder.save() - self.assertEquals(rule.pk, initial_rule.pk) - self.assertEquals(url, None) + initial_rule.refresh_from_db() + + self.assertEquals(initial_rule.website_url, None) diff --git a/src/newsreader/news/collection/tests/twitter/__init__.py b/src/newsreader/news/collection/tests/twitter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/twitter/builder/__init__.py b/src/newsreader/news/collection/tests/twitter/builder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/twitter/builder/mocks.py b/src/newsreader/news/collection/tests/twitter/builder/mocks.py new file mode 100644 index 0000000..b330f2f --- /dev/null +++ b/src/newsreader/news/collection/tests/twitter/builder/mocks.py @@ -0,0 +1,2187 @@ +# retrieved with: +# curl -X GET -H "Authorization: Bearer " "https://api.twitter.com/1.1/statuses/user_timeline.json?screen_name=twitterapi&tweet_mode=extended" | python3 -m json.tool --sort-keys +# +# see https://developer.twitter.com/en/docs/twitter-api/v1/data-dictionary/overview/tweet-object +# and https://developer.twitter.com/en/docs/twitter-api/v1/data-dictionary/overview/extended-entities-object +# for more information about tweet objects + +simple_mock = [ + { + "contributors": None, + "coordinates": None, + "created_at": "Fri Aug 07 00:17:05 +0000 2020", + "display_text_range": [11, 59], + "entities": { + "hashtags": [], + "symbols": [], + "urls": [ + { + "display_url": "youtu.be/rDy7tPf6CT8", + "expanded_url": "https://youtu.be/rDy7tPf6CT8", + "indices": [36, 59], + "url": "https://t.co/trAcIxBMlX", + } + ], + "user_mentions": [ + { + "id": 975844884606275587, + "id_str": "975844884606275587", + "indices": [0, 10], + "name": "ArieNeo", + "screen_name": "ArieNeoSC", + } + ], + }, + "favorite_count": 19, + "favorited": False, + "full_text": "@ArieNeoSC Here you go, goodnight!\n\nhttps://t.co/trAcIxBMlX", + "geo": None, + "id": 1291528756373286914, + "id_str": "1291528756373286914", + "in_reply_to_screen_name": "ArieNeoSC", + "in_reply_to_status_id": 1291507356313038850, + "in_reply_to_status_id_str": "1291507356313038850", + "in_reply_to_user_id": 975844884606275587, + "in_reply_to_user_id_str": "975844884606275587", + "is_quote_status": False, + "lang": "en", + "place": None, + "possibly_sensitive": False, + "retweet_count": 5, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4588, + "follow_request_sent": None, + "followers_count": 106169, + "following": None, + "friends_count": 201, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 890, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6210, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, + { + "contributors": None, + "coordinates": None, + "created_at": "Wed Jul 29 19:01:47 +0000 2020", + "display_text_range": [10, 98], + "entities": { + "hashtags": [], + "symbols": [], + "urls": [], + "user_mentions": [ + { + "id": 435221600, + "id_str": "435221600", + "indices": [0, 9], + "name": "Christopher Blough", + "screen_name": "RelicCcb", + } + ], + }, + "favorite_count": 1, + "favorited": False, + "full_text": "@RelicCcb Hi Christoper, we have checked the status of your investigation and it is still ongoing.", + "geo": None, + "id": 1288550304095416320, + "id_str": "1288550304095416320", + "in_reply_to_screen_name": "RelicCcb", + "in_reply_to_status_id": 1288475147951898625, + "in_reply_to_status_id_str": "1288475147951898625", + "in_reply_to_user_id": 435221600, + "in_reply_to_user_id_str": "435221600", + "is_quote_status": False, + "lang": "en", + "place": None, + "retweet_count": 0, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4588, + "follow_request_sent": None, + "followers_count": 106169, + "following": None, + "friends_count": 201, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 890, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6210, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, +] + +# contains tweets with "extended_entities" keys which contains native media objects +# which are in this case of the type "photo" +image_mock = [ + { + "contributors": None, + "coordinates": None, + "created_at": "Fri Jun 05 22:51:46 +0000 2020", + "entities": { + "hashtags": [], + "media": [ + { + "display_url": "pic.twitter.com/VjEeDrL1iA", + "expanded_url": "https://twitter.com/knxwledge/status/1269039237166321664/photo/1", + "id": 1269039233072689152, + "id_str": "1269039233072689152", + "indices": [2, 25], + "media_url": "http://pbs.twimg.com/media/EZyIdXVU8AACPCz.jpg", + "media_url_https": "https://pbs.twimg.com/media/EZyIdXVU8AACPCz.jpg", + "sizes": { + "large": {"h": 1073, "resize": "fit", "w": 1125}, + "medium": {"h": 1073, "resize": "fit", "w": 1125}, + "small": {"h": 649, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/VjEeDrL1iA", + } + ], + "symbols": [], + "urls": [], + "user_mentions": [], + }, + "extended_entities": { + "media": [ + { + "display_url": "pic.twitter.com/VjEeDrL1iA", + "expanded_url": "https://twitter.com/knxwledge/status/1269039237166321664/photo/1", + "id": 1269039233072689152, + "id_str": "1269039233072689152", + "indices": [2, 25], + "media_url": "http://pbs.twimg.com/media/EZyIdXVU8AACPCz.jpg", + "media_url_https": "https://pbs.twimg.com/media/EZyIdXVU8AACPCz.jpg", + "sizes": { + "large": {"h": 1073, "resize": "fit", "w": 1125}, + "medium": {"h": 1073, "resize": "fit", "w": 1125}, + "small": {"h": 649, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/VjEeDrL1iA", + }, + { + "display_url": "pic.twitter.com/VjEeDrL1iA", + "expanded_url": "https://twitter.com/knxwledge/status/1269039237166321664/photo/1", + "id": 1269039233068527618, + "id_str": "1269039233068527618", + "indices": [2, 25], + "media_url": "http://pbs.twimg.com/media/EZyIdXUVcAI3Cju.jpg", + "media_url_https": "https://pbs.twimg.com/media/EZyIdXUVcAI3Cju.jpg", + "sizes": { + "large": {"h": 992, "resize": "fit", "w": 1472}, + "medium": {"h": 809, "resize": "fit", "w": 1200}, + "small": {"h": 458, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/VjEeDrL1iA", + }, + ] + }, + "favorite_count": 2139, + "favorited": False, + "geo": None, + "id": 1269039237166321664, + "id_str": "1269039237166321664", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "und", + "place": None, + "possibly_sensitive": False, + "possibly_sensitive_appealable": False, + "retweet_count": 427, + "retweeted": False, + "source": 'Twitter for iPhone', + "full_text": "_ https://t.co/VjEeDrL1iA", + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Tue Nov 14 19:00:00 +0000 2017", + "default_profile": False, + "default_profile_image": False, + "description": "Grammy\u00ae Award Winning Beatmakr. https://t.co/SN23ei3EeC https://t.co/EkGRhZ1Bw9 https://t.co/eEb4NOmJLo", + "entities": { + "description": { + "urls": [ + { + "display_url": "soundcloud.com/knxwledge", + "expanded_url": "http://soundcloud.com/knxwledge", + "indices": [32, 55], + "url": "https://t.co/SN23ei3EeC", + }, + { + "display_url": "knxwledge.bandcamp.com", + "expanded_url": "http://knxwledge.bandcamp.com", + "indices": [56, 79], + "url": "https://t.co/EkGRhZ1Bw9", + }, + { + "display_url": "twitch.tv/knxwledge", + "expanded_url": "http://twitch.tv/knxwledge", + "indices": [80, 103], + "url": "https://t.co/eEb4NOmJLo", + }, + ] + }, + "url": { + "urls": [ + { + "display_url": "instagram.com/knxwledge/?hl=\u2026", + "expanded_url": "https://www.instagram.com/knxwledge/?hl=en", + "indices": [0, 23], + "url": "https://t.co/UcMYfiQXLx", + } + ] + }, + }, + "favourites_count": 363, + "follow_request_sent": None, + "followers_count": 31194, + "following": None, + "friends_count": 15, + "geo_enabled": False, + "has_extended_profile": False, + "id": 930510644763287552, + "id_str": "930510644763287552", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 56, + "location": "", + "name": "knxwledge", + "notifications": None, + "profile_background_color": "000000", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme1/bg.png", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme1/bg.png", + "profile_background_tile": False, + "profile_image_url": "http://pbs.twimg.com/profile_images/1274913160898592768/jFi4VDtJ_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/1274913160898592768/jFi4VDtJ_normal.jpg", + "profile_link_color": "ABB8C2", + "profile_sidebar_border_color": "000000", + "profile_sidebar_fill_color": "000000", + "profile_text_color": "000000", + "profile_use_background_image": False, + "protected": False, + "screen_name": "knxwledge", + "statuses_count": 713, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/UcMYfiQXLx", + "utc_offset": None, + "verified": False, + }, + } +] + +# contains tweets with "extended_entities" keys which contains native media objects +# which are in this case of the type "video" +video_mock = [ + { + "contributors": None, + "coordinates": None, + "created_at": "Wed Aug 05 18:36:00 +0000 2020", + "display_text_range": [0, 196], + "entities": { + "hashtags": [], + "media": [ + { + "display_url": "pic.twitter.com/mZ8CAuq3SH", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1291080532361527296/video/1", + "id": 1291074294747770880, + "id_str": "1291074294747770880", + "indices": [197, 220], + "media_url": "http://pbs.twimg.com/media/EerWyexUEAQhRL1.jpg", + "media_url_https": "https://pbs.twimg.com/media/EerWyexUEAQhRL1.jpg", + "sizes": { + "large": {"h": 720, "resize": "fit", "w": 1280}, + "medium": {"h": 675, "resize": "fit", "w": 1200}, + "small": {"h": 383, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/mZ8CAuq3SH", + } + ], + "symbols": [], + "urls": [ + { + "display_url": "robertsspaceindustries.com/greycatroc", + "expanded_url": "http://robertsspaceindustries.com/greycatroc", + "indices": [173, 196], + "url": "https://t.co/2aH7qdOfSk", + } + ], + "user_mentions": [], + }, + "extended_entities": { + "media": [ + { + "additional_media_info": { + "description": "", + "embeddable": True, + "monetizable": False, + "title": "", + }, + "display_url": "pic.twitter.com/mZ8CAuq3SH", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1291080532361527296/video/1", + "id": 1291074294747770880, + "id_str": "1291074294747770880", + "indices": [197, 220], + "media_url": "http://pbs.twimg.com/media/EerWyexUEAQhRL1.jpg", + "media_url_https": "https://pbs.twimg.com/media/EerWyexUEAQhRL1.jpg", + "sizes": { + "large": {"h": 720, "resize": "fit", "w": 1280}, + "medium": {"h": 675, "resize": "fit", "w": 1200}, + "small": {"h": 383, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "video", + "url": "https://t.co/mZ8CAuq3SH", + "video_info": { + "aspect_ratio": [16, 9], + "duration_millis": 82967, + "variants": [ + { + "content_type": "application/x-mpegURL", + "url": "https://video.twimg.com/amplify_video/1291074294747770880/pl/kMYgFEoRyoW99o-i.m3u8?tag=13", + }, + { + "bitrate": 2176000, + "content_type": "video/mp4", + "url": "https://video.twimg.com/amplify_video/1291074294747770880/vid/1280x720/J05_p6q74ZUN4csg.mp4?tag=13", + }, + { + "bitrate": 832000, + "content_type": "video/mp4", + "url": "https://video.twimg.com/amplify_video/1291074294747770880/vid/640x360/ya3fVKeRdBs3cOoF.mp4?tag=13", + }, + { + "bitrate": 288000, + "content_type": "video/mp4", + "url": "https://video.twimg.com/amplify_video/1291074294747770880/vid/480x270/WQkAozOts-hRoU1I.mp4?tag=13", + }, + ], + }, + } + ] + }, + "favorite_count": 289, + "favorited": False, + "full_text": "Small enough to access hard-to-reach ore deposits, but with enough power to get through the tough jobs, Greycat\u2019s ROC perfectly complements any mining operation. \n\nDetails: https://t.co/2aH7qdOfSk https://t.co/mZ8CAuq3SH", + "geo": None, + "id": 1291080532361527296, + "id_str": "1291080532361527296", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "possibly_sensitive": False, + "retweet_count": 64, + "retweeted": False, + "source": 'Twitter Media Studio', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4588, + "follow_request_sent": None, + "followers_count": 106169, + "following": None, + "friends_count": 201, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 890, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6210, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, + { + "contributors": None, + "coordinates": None, + "created_at": "Wed Aug 05 18:31:27 +0000 2020", + "display_text_range": [0, 213], + "entities": { + "hashtags": [{"indices": [157, 169], "text": "StarCitizen"}], + "media": [ + { + "display_url": "pic.twitter.com/lri5QijMoA", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1291079386821582849/video/1", + "id": 1291070740347813889, + "id_str": "1291070740347813889", + "indices": [214, 237], + "media_url": "http://pbs.twimg.com/media/EerUMgyUwAAgj9w.jpg", + "media_url_https": "https://pbs.twimg.com/media/EerUMgyUwAAgj9w.jpg", + "sizes": { + "large": {"h": 720, "resize": "fit", "w": 1280}, + "medium": {"h": 675, "resize": "fit", "w": 1200}, + "small": {"h": 383, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/lri5QijMoA", + } + ], + "symbols": [], + "urls": [ + { + "display_url": "robertsspaceindustries.com/comm-link/tran\u2026", + "expanded_url": "https://robertsspaceindustries.com/comm-link/transmission/17648-Alpha-310-Flight-Fight", + "indices": [190, 213], + "url": "https://t.co/6jT1yuZMiR", + } + ], + "user_mentions": [], + }, + "extended_entities": { + "media": [ + { + "additional_media_info": { + "description": "", + "embeddable": True, + "monetizable": False, + "title": "", + }, + "display_url": "pic.twitter.com/lri5QijMoA", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1291079386821582849/video/1", + "id": 1291070740347813889, + "id_str": "1291070740347813889", + "indices": [214, 237], + "media_url": "http://pbs.twimg.com/media/EerUMgyUwAAgj9w.jpg", + "media_url_https": "https://pbs.twimg.com/media/EerUMgyUwAAgj9w.jpg", + "sizes": { + "large": {"h": 720, "resize": "fit", "w": 1280}, + "medium": {"h": 675, "resize": "fit", "w": 1200}, + "small": {"h": 383, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "video", + "url": "https://t.co/lri5QijMoA", + "video_info": { + "aspect_ratio": [16, 9], + "duration_millis": 83633, + "variants": [ + { + "bitrate": 288000, + "content_type": "video/mp4", + "url": "https://video.twimg.com/amplify_video/1291070740347813889/vid/480x270/oGdSeLr5QQ-XcTns.mp4?tag=13", + }, + { + "bitrate": 2176000, + "content_type": "video/mp4", + "url": "https://video.twimg.com/amplify_video/1291070740347813889/vid/1280x720/bql0evKsgYZhGPNP.mp4?tag=13", + }, + { + "bitrate": 832000, + "content_type": "video/mp4", + "url": "https://video.twimg.com/amplify_video/1291070740347813889/vid/640x360/lSL6mqB53HnwrUo4.mp4?tag=13", + }, + { + "content_type": "application/x-mpegURL", + "url": "https://video.twimg.com/amplify_video/1291070740347813889/pl/_jJ-AYWSMr8ZS1WP.m3u8?tag=13", + }, + ], + }, + } + ] + }, + "favorite_count": 429, + "favorited": False, + "full_text": "Harness the power of improved high-speed dynamic combat. Feel the thrill of atmospheric flight like never before. Alpha 3.10 will change the way you play. \ud83d\ude80 #StarCitizen\n\nGet in the 'verse: https://t.co/6jT1yuZMiR https://t.co/lri5QijMoA", + "geo": None, + "id": 1291079386821582849, + "id_str": "1291079386821582849", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "possibly_sensitive": False, + "retweet_count": 117, + "retweeted": False, + "source": 'Twitter Media Studio', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4588, + "follow_request_sent": None, + "followers_count": 106169, + "following": None, + "friends_count": 201, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 890, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6210, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, +] + +video_without_bitrate_mock = [ + { + "contributors": None, + "coordinates": None, + "created_at": "Wed Aug 05 18:36:00 +0000 2020", + "display_text_range": [0, 196], + "entities": { + "hashtags": [], + "media": [ + { + "display_url": "pic.twitter.com/mZ8CAuq3SH", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1291080532361527296/video/1", + "id": 1291074294747770880, + "id_str": "1291074294747770880", + "indices": [197, 220], + "media_url": "http://pbs.twimg.com/media/EerWyexUEAQhRL1.jpg", + "media_url_https": "https://pbs.twimg.com/media/EerWyexUEAQhRL1.jpg", + "sizes": { + "large": {"h": 720, "resize": "fit", "w": 1280}, + "medium": {"h": 675, "resize": "fit", "w": 1200}, + "small": {"h": 383, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/mZ8CAuq3SH", + } + ], + "symbols": [], + "urls": [ + { + "display_url": "robertsspaceindustries.com/greycatroc", + "expanded_url": "http://robertsspaceindustries.com/greycatroc", + "indices": [173, 196], + "url": "https://t.co/2aH7qdOfSk", + } + ], + "user_mentions": [], + }, + "extended_entities": { + "media": [ + { + "additional_media_info": { + "description": "", + "embeddable": True, + "monetizable": False, + "title": "", + }, + "display_url": "pic.twitter.com/mZ8CAuq3SH", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1291080532361527296/video/1", + "id": 1291074294747770880, + "id_str": "1291074294747770880", + "indices": [197, 220], + "media_url": "http://pbs.twimg.com/media/EerWyexUEAQhRL1.jpg", + "media_url_https": "https://pbs.twimg.com/media/EerWyexUEAQhRL1.jpg", + "sizes": { + "large": {"h": 720, "resize": "fit", "w": 1280}, + "medium": {"h": 675, "resize": "fit", "w": 1200}, + "small": {"h": 383, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "video", + "url": "https://t.co/mZ8CAuq3SH", + "video_info": { + "aspect_ratio": [16, 9], + "duration_millis": 82967, + "variants": [ + { + "content_type": "application/x-mpegURL", + "url": "https://video.twimg.com/amplify_video/1291074294747770880/pl/kMYgFEoRyoW99o-i.m3u8?tag=13", + } + ], + }, + } + ] + }, + "favorite_count": 289, + "favorited": False, + "full_text": "Small enough to access hard-to-reach ore deposits, but with enough power to get through the tough jobs, Greycat\u2019s ROC perfectly complements any mining operation. \n\nDetails: https://t.co/2aH7qdOfSk https://t.co/mZ8CAuq3SH", + "geo": None, + "id": 1291080532361527296, + "id_str": "1291080532361527296", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "possibly_sensitive": False, + "retweet_count": 64, + "retweeted": False, + "source": 'Twitter Media Studio', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4588, + "follow_request_sent": None, + "followers_count": 106169, + "following": None, + "friends_count": 201, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 890, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6210, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + } +] + +# contains tweets with a "retweeted_status" key containing the retweeted tweet. +# the "retweet" cannot add hashtags, URLs or other details, see https://developer.twitter.com/en/docs/twitter-api/v1/data-dictionary/overview/entities-object#retweets-quote +retweet_mock = [ + { + "contributors": None, + "coordinates": None, + "created_at": "Wed Aug 05 21:01:02 +0000 2020", + "display_text_range": [0, 140], + "entities": { + "hashtags": [{"indices": [27, 39], "text": "StarCitizen"}], + "symbols": [], + "urls": [], + "user_mentions": [ + { + "id": 859293278100914176, + "id_str": "859293278100914176", + "indices": [3, 14], + "name": "Aleksandr Belov", + "screen_name": "Narayan_N7", + } + ], + }, + "favorite_count": 0, + "favorited": False, + "full_text": "RT @Narayan_N7: New video! #StarCitizen 3.9 vs. 3.10 comparison!\nSo, the patch 3.10 came out, which brought us quite a lot of changes!\ud83d\ude42\nPle\u2026", + "geo": None, + "id": 1291117030486106112, + "id_str": "1291117030486106112", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "retweet_count": 26, + "retweeted": False, + "retweeted_status": { + "contributors": None, + "coordinates": None, + "created_at": "Wed Aug 05 18:15:34 +0000 2020", + "display_text_range": [0, 250], + "entities": { + "hashtags": [{"indices": [11, 23], "text": "StarCitizen"}], + "symbols": [], + "urls": [ + { + "display_url": "youtu.be/aXXGnCbEas0", + "expanded_url": "https://youtu.be/aXXGnCbEas0", + "indices": [227, 250], + "url": "https://t.co/j4QahHzbw4", + } + ], + "user_mentions": [ + { + "id": 803542770, + "id_str": "803542770", + "indices": [193, 209], + "name": "Star Citizen", + "screen_name": "RobertsSpaceInd", + }, + { + "id": 803697073, + "id_str": "803697073", + "indices": [211, 225], + "name": "Cloud Imperium Games", + "screen_name": "CloudImperium", + }, + ], + }, + "favorite_count": 97, + "favorited": False, + "full_text": "New video! #StarCitizen 3.9 vs. 3.10 comparison!\nSo, the patch 3.10 came out, which brought us quite a lot of changes!\ud83d\ude42\nPlease, share it with your friends!\ud83d\ude4f\n\nEnjoy watching and stay safe! \u2764\ufe0f\u263a\ufe0f\n@RobertsSpaceInd\n\n@CloudImperium\n\nhttps://t.co/j4QahHzbw4", + "geo": None, + "id": 1291075388798533633, + "id_str": "1291075388798533633", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "possibly_sensitive": False, + "retweet_count": 26, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Tue May 02 06:27:37 +0000 2017", + "default_profile": True, + "default_profile_image": False, + "description": "Enlist to Star Citizen: https://t.co/JOei50wjGK Content creator. #IWantToWorkAtCIG \n#StarCitizen #video #youtube #flickr #4K #panorama", + "entities": { + "description": { + "urls": [ + { + "display_url": "goo.gl/8CbEZm", + "expanded_url": "http://goo.gl/8CbEZm", + "indices": [24, 47], + "url": "https://t.co/JOei50wjGK", + } + ] + }, + "url": { + "urls": [ + { + "display_url": "youtube.com/user/sashaMOHC\u2026", + "expanded_url": "https://www.youtube.com/user/sashaMOHCTPwhite", + "indices": [0, 23], + "url": "https://t.co/ise14uN9Ja", + } + ] + }, + }, + "favourites_count": 1882, + "follow_request_sent": None, + "followers_count": 489, + "following": None, + "friends_count": 80, + "geo_enabled": True, + "has_extended_profile": True, + "id": 859293278100914176, + "id_str": "859293278100914176", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 16, + "location": "\u0421\u0430\u043d\u043a\u0442-\u041f\u0435\u0442\u0435\u0440\u0431\u0443\u0440\u0433, \u0420\u043e\u0441\u0441\u0438\u044f", + "name": "Aleksandr Belov", + "notifications": None, + "profile_background_color": "F5F8FA", + "profile_background_image_url": None, + "profile_background_image_url_https": None, + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/859293278100914176/1576841460", + "profile_image_url": "http://pbs.twimg.com/profile_images/1203066581573607425/5TEkxVJ3_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/1203066581573607425/5TEkxVJ3_normal.jpg", + "profile_link_color": "1DA1F2", + "profile_sidebar_border_color": "C0DEED", + "profile_sidebar_fill_color": "DDEEF6", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "Narayan_N7", + "statuses_count": 1283, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/ise14uN9Ja", + "utc_offset": None, + "verified": False, + }, + }, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4588, + "follow_request_sent": None, + "followers_count": 106169, + "following": None, + "friends_count": 201, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 890, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6210, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, + { + "contributors": None, + "coordinates": None, + "created_at": "Thu Jul 30 13:15:25 +0000 2020", + "display_text_range": [0, 140], + "entities": { + "hashtags": [{"indices": [24, 40], "text": "CountdownToMars"}], + "symbols": [], + "urls": [], + "user_mentions": [ + { + "id": 11348282, + "id_str": "11348282", + "indices": [3, 8], + "name": "NASA", + "screen_name": "NASA", + }, + { + "id": 1232783237623119872, + "id_str": "1232783237623119872", + "indices": [123, 137], + "name": "NASA's Perseverance Mars Rover", + "screen_name": "NASAPersevere", + }, + ], + }, + "favorite_count": 0, + "favorited": False, + "full_text": "RT @NASA: LIVE NOW: The #CountdownToMars begins. \n\nWe are launching a historic mission to the Red Planet. Tune in to watch @NASAPersevere l\u2026", + "geo": None, + "id": 1288825524878336000, + "id_str": "1288825524878336000", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "retweet_count": 8867, + "retweeted": False, + "retweeted_status": { + "contributors": None, + "coordinates": None, + "created_at": "Thu Jul 30 11:01:06 +0000 2020", + "display_text_range": [0, 236], + "entities": { + "hashtags": [{"indices": [14, 30], "text": "CountdownToMars"}], + "symbols": [], + "urls": [ + { + "display_url": "twitter.com/i/broadcasts/1\u2026", + "expanded_url": "https://twitter.com/i/broadcasts/1RDGlrkoEzNxL", + "indices": [213, 236], + "url": "https://t.co/JxyRCol01i", + } + ], + "user_mentions": [ + { + "id": 1232783237623119872, + "id_str": "1232783237623119872", + "indices": [113, 127], + "name": "NASA's Perseverance Mars Rover", + "screen_name": "NASAPersevere", + } + ], + }, + "favorite_count": 18327, + "favorited": False, + "full_text": "LIVE NOW: The #CountdownToMars begins. \n\nWe are launching a historic mission to the Red Planet. Tune in to watch @NASAPersevere liftoff and begin her mission to search for signs of ancient life on another world: https://t.co/JxyRCol01i", + "geo": None, + "id": 1288791726165983233, + "id_str": "1288791726165983233", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "possibly_sensitive": False, + "retweet_count": 8867, + "retweeted": False, + "source": 'Twitter Media Studio', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Dec 19 20:20:32 +0000 2007", + "default_profile": False, + "default_profile_image": False, + "description": "Explore the universe and our home planet with NASA \ud83c\udf0e We usually post in EDT.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "nasa.gov", + "expanded_url": "http://www.nasa.gov/", + "indices": [0, 23], + "url": "https://t.co/HMJJbimQpV", + } + ] + }, + }, + "favourites_count": 11658, + "follow_request_sent": None, + "followers_count": 39440029, + "following": None, + "friends_count": 222, + "geo_enabled": False, + "has_extended_profile": True, + "id": 11348282, + "id_str": "11348282", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 92535, + "location": "", + "name": "NASA", + "notifications": None, + "profile_background_color": "000000", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme1/bg.png", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme1/bg.png", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/11348282/1596217000", + "profile_image_url": "http://pbs.twimg.com/profile_images/1091070803184177153/TI2qItoi_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/1091070803184177153/TI2qItoi_normal.jpg", + "profile_link_color": "205BA7", + "profile_sidebar_border_color": "000000", + "profile_sidebar_fill_color": "F3F2F2", + "profile_text_color": "000000", + "profile_use_background_image": True, + "protected": False, + "screen_name": "NASA", + "statuses_count": 61920, + "time_zone": None, + "translator_type": "regular", + "url": "https://t.co/HMJJbimQpV", + "utc_offset": None, + "verified": True, + }, + }, + "source": 'Twitter for iPhone', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4588, + "follow_request_sent": None, + "followers_count": 106169, + "following": None, + "friends_count": 201, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 890, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6210, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, +] + +# contains tweets with a "quoted_status" key containing the quoted tweet. +# quoted tweets can add hashtags, URL's and other details as it adds content "on top" of the quoted tweet see https://developer.twitter.com/en/docs/twitter-api/v1/data-dictionary/overview/entities-object#retweets-quotes +quoted_mock = [ + { + "contributors": None, + "coordinates": None, + "created_at": "Wed Aug 05 00:05:24 +0000 2020", + "display_text_range": [0, 13], + "entities": { + "hashtags": [], + "symbols": [], + "urls": [ + { + "display_url": "twitter.com/hugolisoir/sta\u2026", + "expanded_url": "https://twitter.com/hugolisoir/status/1290778178793897992", + "indices": [14, 37], + "url": "https://t.co/WyznJwCJLp", + } + ], + "user_mentions": [], + }, + "favorite_count": 576, + "favorited": False, + "full_text": "Bonne nuit \ud83c\udf3a\ud83d\udeeb https://t.co/WyznJwCJLp", + "geo": None, + "id": 1290801039075979264, + "id_str": "1290801039075979264", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": True, + "lang": "fr", + "place": None, + "possibly_sensitive": False, + "quoted_status": { + "contributors": None, + "coordinates": None, + "created_at": "Tue Aug 04 22:34:33 +0000 2020", + "display_text_range": [0, 57], + "entities": { + "hashtags": [{"indices": [0, 12], "text": "Starcitizen"}], + "media": [ + { + "display_url": "pic.twitter.com/xCXun68V3r", + "expanded_url": "https://twitter.com/hugolisoir/status/1290778178793897992/video/1", + "id": 1290778053623382017, + "id_str": "1290778053623382017", + "indices": [58, 81], + "media_url": "http://pbs.twimg.com/ext_tw_video_thumb/1290778053623382017/pu/img/FFHKsCa_gYLNrriu.jpg", + "media_url_https": "https://pbs.twimg.com/ext_tw_video_thumb/1290778053623382017/pu/img/FFHKsCa_gYLNrriu.jpg", + "sizes": { + "large": {"h": 720, "resize": "fit", "w": 1280}, + "medium": {"h": 675, "resize": "fit", "w": 1200}, + "small": {"h": 383, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/xCXun68V3r", + } + ], + "symbols": [], + "urls": [], + "user_mentions": [ + { + "id": 803542770, + "id_str": "803542770", + "indices": [41, 57], + "name": "Star Citizen", + "screen_name": "RobertsSpaceInd", + } + ], + }, + "extended_entities": { + "media": [ + { + "additional_media_info": {"monetizable": False}, + "display_url": "pic.twitter.com/xCXun68V3r", + "expanded_url": "https://twitter.com/hugolisoir/status/1290778178793897992/video/1", + "id": 1290778053623382017, + "id_str": "1290778053623382017", + "indices": [58, 81], + "media_url": "http://pbs.twimg.com/ext_tw_video_thumb/1290778053623382017/pu/img/FFHKsCa_gYLNrriu.jpg", + "media_url_https": "https://pbs.twimg.com/ext_tw_video_thumb/1290778053623382017/pu/img/FFHKsCa_gYLNrriu.jpg", + "sizes": { + "large": {"h": 720, "resize": "fit", "w": 1280}, + "medium": {"h": 675, "resize": "fit", "w": 1200}, + "small": {"h": 383, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "video", + "url": "https://t.co/xCXun68V3r", + "video_info": { + "aspect_ratio": [16, 9], + "duration_millis": 39901, + "variants": [ + { + "bitrate": 832000, + "content_type": "video/mp4", + "url": "https://video.twimg.com/ext_tw_video/1290778053623382017/pu/vid/640x360/jYjO0H2SYSycTi-e.mp4?tag=10", + }, + { + "content_type": "application/x-mpegURL", + "url": "https://video.twimg.com/ext_tw_video/1290778053623382017/pu/pl/wFnVMLjVWi7OKy2o.m3u8?tag=10", + }, + { + "bitrate": 2176000, + "content_type": "video/mp4", + "url": "https://video.twimg.com/ext_tw_video/1290778053623382017/pu/vid/1280x720/H-BXvYdM0AcSKXpk.mp4?tag=10", + }, + { + "bitrate": 256000, + "content_type": "video/mp4", + "url": "https://video.twimg.com/ext_tw_video/1290778053623382017/pu/vid/480x270/aWhSjP1gK7djKZUK.mp4?tag=10", + }, + ], + }, + } + ] + }, + "favorite_count": 400, + "favorited": False, + "full_text": "#Starcitizen Le jeu est beau. Bonne nuit @RobertsSpaceInd https://t.co/xCXun68V3r", + "geo": None, + "id": 1290778178793897992, + "id_str": "1290778178793897992", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "fr", + "place": None, + "possibly_sensitive": False, + "retweet_count": 76, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Tue Mar 22 12:00:36 +0000 2011", + "default_profile": False, + "default_profile_image": False, + "description": "Youtuber Partner / Twitch Partner / Membre du @CurryClub_CC\nInsta - hugolisoir\nParrain de @AbyssalProject", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "youtube.com/channel/UCDC6D\u2026", + "expanded_url": "https://www.youtube.com/channel/UCDC6DBi0kRp6Jk21xqfvFLA", + "indices": [0, 23], + "url": "https://t.co/p3CVR2I068", + } + ] + }, + }, + "favourites_count": 20935, + "follow_request_sent": None, + "followers_count": 23269, + "following": None, + "friends_count": 703, + "geo_enabled": True, + "has_extended_profile": False, + "id": 270320632, + "id_str": "270320632", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 116, + "location": "Nantes, France", + "name": "Hugo Lisoir #ZLAN2020", + "notifications": None, + "profile_background_color": "000000", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme15/bg.png", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme15/bg.png", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/270320632/1499086260", + "profile_image_url": "http://pbs.twimg.com/profile_images/1264841251305730048/vyUJVCvW_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/1264841251305730048/vyUJVCvW_normal.jpg", + "profile_link_color": "ABB8C2", + "profile_sidebar_border_color": "000000", + "profile_sidebar_fill_color": "000000", + "profile_text_color": "000000", + "profile_use_background_image": False, + "protected": False, + "screen_name": "hugolisoir", + "statuses_count": 7507, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/p3CVR2I068", + "utc_offset": None, + "verified": False, + }, + }, + "quoted_status_id": 1290778178793897992, + "quoted_status_id_str": "1290778178793897992", + "quoted_status_permalink": { + "display": "twitter.com/hugolisoir/sta\u2026", + "expanded": "https://twitter.com/hugolisoir/status/1290778178793897992", + "url": "https://t.co/WyznJwCJLp", + }, + "retweet_count": 60, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4588, + "follow_request_sent": None, + "followers_count": 106169, + "following": None, + "friends_count": 201, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 890, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6210, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, + { + "contributors": None, + "coordinates": None, + "created_at": "Fri Jul 31 22:00:55 +0000 2020", + "display_text_range": [0, 32], + "entities": { + "hashtags": [], + "symbols": [], + "urls": [ + { + "display_url": "twitter.com/UberFacts/stat\u2026", + "expanded_url": "https://twitter.com/UberFacts/status/1289273883493675009", + "indices": [33, 56], + "url": "https://t.co/LLPVr8oU7F", + } + ], + "user_mentions": [], + }, + "favorite_count": 263, + "favorited": False, + "full_text": "Here's to our lovely Avocados! \ud83d\udd79 https://t.co/LLPVr8oU7F", + "geo": None, + "id": 1289320160021495809, + "id_str": "1289320160021495809", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": True, + "lang": "en", + "place": None, + "possibly_sensitive": False, + "quoted_status": { + "contributors": None, + "coordinates": None, + "created_at": "Fri Jul 31 18:57:02 +0000 2020", + "display_text_range": [0, 34], + "entities": { + "hashtags": [], + "media": [ + { + "display_url": "pic.twitter.com/8QRycx9QB2", + "expanded_url": "https://twitter.com/UberFacts/status/1289273883493675009/photo/1", + "id": 1289273880570363907, + "id_str": "1289273880570363907", + "indices": [35, 58], + "media_url": "http://pbs.twimg.com/tweet_video_thumb/EeRrw3WWAAMKVF0.jpg", + "media_url_https": "https://pbs.twimg.com/tweet_video_thumb/EeRrw3WWAAMKVF0.jpg", + "sizes": { + "large": {"h": 500, "resize": "fit", "w": 500}, + "medium": {"h": 500, "resize": "fit", "w": 500}, + "small": {"h": 500, "resize": "fit", "w": 500}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/8QRycx9QB2", + } + ], + "symbols": [], + "urls": [], + "user_mentions": [], + }, + "extended_entities": { + "media": [ + { + "display_url": "pic.twitter.com/8QRycx9QB2", + "expanded_url": "https://twitter.com/UberFacts/status/1289273883493675009/photo/1", + "id": 1289273880570363907, + "id_str": "1289273880570363907", + "indices": [35, 58], + "media_url": "http://pbs.twimg.com/tweet_video_thumb/EeRrw3WWAAMKVF0.jpg", + "media_url_https": "https://pbs.twimg.com/tweet_video_thumb/EeRrw3WWAAMKVF0.jpg", + "sizes": { + "large": {"h": 500, "resize": "fit", "w": 500}, + "medium": {"h": 500, "resize": "fit", "w": 500}, + "small": {"h": 500, "resize": "fit", "w": 500}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "animated_gif", + "url": "https://t.co/8QRycx9QB2", + "video_info": { + "aspect_ratio": [1, 1], + "variants": [ + { + "bitrate": 0, + "content_type": "video/mp4", + "url": "https://video.twimg.com/tweet_video/EeRrw3WWAAMKVF0.mp4", + } + ], + }, + } + ] + }, + "favorite_count": 1550, + "favorited": False, + "full_text": "July 31st is National Avocado Day! https://t.co/8QRycx9QB2", + "geo": None, + "id": 1289273883493675009, + "id_str": "1289273883493675009", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "possibly_sensitive": False, + "retweet_count": 380, + "retweeted": False, + "source": 'Buffer', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Sun Dec 06 16:07:01 +0000 2009", + "default_profile": False, + "default_profile_image": False, + "description": "The most unimportant things you'll never need to know.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "uber-facts.com", + "expanded_url": "http://uber-facts.com/", + "indices": [0, 23], + "url": "https://t.co/3ycpGqEL9n", + } + ] + }, + }, + "favourites_count": 1297, + "follow_request_sent": None, + "followers_count": 13810392, + "following": None, + "friends_count": 1, + "geo_enabled": True, + "has_extended_profile": False, + "id": 95023423, + "id_str": "95023423", + "is_translation_enabled": True, + "is_translator": False, + "lang": None, + "listed_count": 15141, + "location": "Worldwide!", + "name": "UberFacts", + "notifications": None, + "profile_background_color": "C0DEED", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme1/bg.png", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme1/bg.png", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/95023423/1587338728", + "profile_image_url": "http://pbs.twimg.com/profile_images/615696617165885440/JDbUuo9H_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/615696617165885440/JDbUuo9H_normal.jpg", + "profile_link_color": "0D9BA8", + "profile_sidebar_border_color": "000000", + "profile_sidebar_fill_color": "FFFFFF", + "profile_text_color": "000000", + "profile_use_background_image": True, + "protected": False, + "screen_name": "UberFacts", + "statuses_count": 202253, + "time_zone": None, + "translator_type": "regular", + "url": "https://t.co/3ycpGqEL9n", + "utc_offset": None, + "verified": True, + }, + }, + "quoted_status_id": 1289273883493675009, + "quoted_status_id_str": "1289273883493675009", + "quoted_status_permalink": { + "display": "twitter.com/UberFacts/stat\u2026", + "expanded": "https://twitter.com/UberFacts/status/1289273883493675009", + "url": "https://t.co/LLPVr8oU7F", + }, + "retweet_count": 24, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4588, + "follow_request_sent": None, + "followers_count": 106169, + "following": None, + "friends_count": 201, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 890, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6210, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, +] + +# contains tweets with "extended_entities" keys which contains native media objects +# which are in this case of the type "animated_gif" +gif_mock = [ + { + "contributors": None, + "coordinates": None, + "created_at": "Fri Jul 31 23:10:55 +0000 2020", + "display_text_range": [12, 12], + "entities": { + "hashtags": [], + "media": [ + { + "display_url": "pic.twitter.com/wxvioLCJ6h", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1289337776140296193/photo/1", + "id": 1289337769521606656, + "id_str": "1289337769521606656", + "indices": [13, 36], + "media_url": "http://pbs.twimg.com/tweet_video_thumb/EeSl3sPUcAAyE4J.jpg", + "media_url_https": "https://pbs.twimg.com/tweet_video_thumb/EeSl3sPUcAAyE4J.jpg", + "sizes": { + "large": {"h": 210, "resize": "fit", "w": 250}, + "medium": {"h": 210, "resize": "fit", "w": 250}, + "small": {"h": 210, "resize": "fit", "w": 250}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/wxvioLCJ6h", + } + ], + "symbols": [], + "urls": [], + "user_mentions": [ + { + "id": 994361231057346561, + "id_str": "994361231057346561", + "indices": [0, 12], + "name": "Xenosystems", + "screen_name": "Xenosystems", + } + ], + }, + "extended_entities": { + "media": [ + { + "display_url": "pic.twitter.com/wxvioLCJ6h", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1289337776140296193/photo/1", + "id": 1289337769521606656, + "id_str": "1289337769521606656", + "indices": [13, 36], + "media_url": "http://pbs.twimg.com/tweet_video_thumb/EeSl3sPUcAAyE4J.jpg", + "media_url_https": "https://pbs.twimg.com/tweet_video_thumb/EeSl3sPUcAAyE4J.jpg", + "sizes": { + "large": {"h": 210, "resize": "fit", "w": 250}, + "medium": {"h": 210, "resize": "fit", "w": 250}, + "small": {"h": 210, "resize": "fit", "w": 250}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "animated_gif", + "url": "https://t.co/wxvioLCJ6h", + "video_info": { + "aspect_ratio": [25, 21], + "variants": [ + { + "bitrate": 0, + "content_type": "video/mp4", + "url": "https://video.twimg.com/tweet_video/EeSl3sPUcAAyE4J.mp4", + } + ], + }, + } + ] + }, + "favorite_count": 13, + "favorited": False, + "full_text": "@Xenosystems https://t.co/wxvioLCJ6h", + "geo": None, + "id": 1289337776140296193, + "id_str": "1289337776140296193", + "in_reply_to_screen_name": "Xenosystems", + "in_reply_to_status_id": 1289324787815178242, + "in_reply_to_status_id_str": "1289324787815178242", + "in_reply_to_user_id": 994361231057346561, + "in_reply_to_user_id_str": "994361231057346561", + "is_quote_status": False, + "lang": "und", + "place": None, + "possibly_sensitive": False, + "retweet_count": 1, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4588, + "follow_request_sent": None, + "followers_count": 106169, + "following": None, + "friends_count": 201, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 890, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6210, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, + { + "contributors": None, + "coordinates": None, + "created_at": "Thu Jul 30 22:30:29 +0000 2020", + "display_text_range": [12, 12], + "entities": { + "hashtags": [], + "media": [ + { + "display_url": "pic.twitter.com/DTbhK1pTc4", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1288965215648849920/photo/1", + "id": 1288965209596420097, + "id_str": "1288965209596420097", + "indices": [13, 36], + "media_url": "http://pbs.twimg.com/tweet_video_thumb/EeNTB2XU4AE-z5Y.jpg", + "media_url_https": "https://pbs.twimg.com/tweet_video_thumb/EeNTB2XU4AE-z5Y.jpg", + "sizes": { + "large": {"h": 278, "resize": "fit", "w": 498}, + "medium": {"h": 278, "resize": "fit", "w": 498}, + "small": {"h": 278, "resize": "fit", "w": 498}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/DTbhK1pTc4", + } + ], + "symbols": [], + "urls": [], + "user_mentions": [ + { + "id": 994361231057346561, + "id_str": "994361231057346561", + "indices": [0, 12], + "name": "Xenosystems", + "screen_name": "Xenosystems", + } + ], + }, + "extended_entities": { + "media": [ + { + "display_url": "pic.twitter.com/DTbhK1pTc4", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1288965215648849920/photo/1", + "id": 1288965209596420097, + "id_str": "1288965209596420097", + "indices": [13, 36], + "media_url": "http://pbs.twimg.com/tweet_video_thumb/EeNTB2XU4AE-z5Y.jpg", + "media_url_https": "https://pbs.twimg.com/tweet_video_thumb/EeNTB2XU4AE-z5Y.jpg", + "sizes": { + "large": {"h": 278, "resize": "fit", "w": 498}, + "medium": {"h": 278, "resize": "fit", "w": 498}, + "small": {"h": 278, "resize": "fit", "w": 498}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "animated_gif", + "url": "https://t.co/DTbhK1pTc4", + "video_info": { + "aspect_ratio": [249, 139], + "variants": [ + { + "bitrate": 0, + "content_type": "video/mp4", + "url": "https://video.twimg.com/tweet_video/EeNTB2XU4AE-z5Y.mp4", + } + ], + }, + } + ] + }, + "favorite_count": 20, + "favorited": False, + "full_text": "@Xenosystems https://t.co/DTbhK1pTc4", + "geo": None, + "id": 1288965215648849920, + "id_str": "1288965215648849920", + "in_reply_to_screen_name": "Xenosystems", + "in_reply_to_status_id": 1288960722349719554, + "in_reply_to_status_id_str": "1288960722349719554", + "in_reply_to_user_id": 994361231057346561, + "in_reply_to_user_id_str": "994361231057346561", + "is_quote_status": False, + "lang": "und", + "place": None, + "possibly_sensitive": False, + "retweet_count": 0, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4588, + "follow_request_sent": None, + "followers_count": 106169, + "following": None, + "friends_count": 201, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 890, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6210, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, +] + +unsanitized_mock = [ + { + "contributors": None, + "coordinates": None, + "created_at": "Fri Aug 07 00:17:05 +0000 2020", + "display_text_range": [11, 59], + "entities": { + "hashtags": [], + "symbols": [], + "urls": [ + { + "display_url": "youtu.be/rDy7tPf6CT8", + "expanded_url": "https://youtu.be/rDy7tPf6CT8", + "indices": [36, 59], + "url": "https://t.co/trAcIxBMlX", + } + ], + "user_mentions": [ + { + "id": 975844884606275587, + "id_str": "975844884606275587", + "indices": [0, 10], + "name": "ArieNeo", + "screen_name": "ArieNeoSC", + } + ], + }, + "favorite_count": 19, + "favorited": False, + "full_text": "@ArieNeoSC Here you go, goodnight!\n\nhttps://t.co/trAcIxBMlX
      ", + "geo": None, + "id": 1291528756373286914, + "id_str": "1291528756373286914", + "in_reply_to_screen_name": "ArieNeoSC", + "in_reply_to_status_id": 1291507356313038850, + "in_reply_to_status_id_str": "1291507356313038850", + "in_reply_to_user_id": 975844884606275587, + "in_reply_to_user_id_str": "975844884606275587", + "is_quote_status": False, + "lang": "en", + "place": None, + "possibly_sensitive": False, + "retweet_count": 5, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4588, + "follow_request_sent": None, + "followers_count": 106169, + "following": None, + "friends_count": 201, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 890, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6210, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + } +] diff --git a/src/newsreader/news/collection/tests/twitter/builder/tests.py b/src/newsreader/news/collection/tests/twitter/builder/tests.py new file mode 100644 index 0000000..37d7ad7 --- /dev/null +++ b/src/newsreader/news/collection/tests/twitter/builder/tests.py @@ -0,0 +1,412 @@ +from datetime import datetime +from unittest.mock import Mock + +from django.test import TestCase +from django.utils.safestring import mark_safe + +import pytz + +from ftfy import fix_text + +from newsreader.news.collection.tests.factories import TwitterTimelineFactory +from newsreader.news.collection.tests.twitter.builder.mocks import ( + gif_mock, + image_mock, + quoted_mock, + retweet_mock, + simple_mock, + unsanitized_mock, + video_mock, + video_without_bitrate_mock, +) +from newsreader.news.collection.twitter import TWITTER_URL, TwitterBuilder +from newsreader.news.collection.utils import truncate_text +from newsreader.news.core.models import Post +from newsreader.news.core.tests.factories import PostFactory + + +class TwitterBuilderTestCase(TestCase): + def setUp(self): + self.maxDiff = None + + def test_simple_post(self): + builder = TwitterBuilder + + profile = TwitterTimelineFactory(screen_name="RobertsSpaceInd") + mock_stream = Mock(rule=profile) + + with builder(simple_mock, mock_stream) as builder: + builder.build() + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual( + ("1291528756373286914", "1288550304095416320"), posts.keys() + ) + + post = posts["1291528756373286914"] + + full_text = ( + "@ArieNeoSC Here you go, goodnight!\n\n" + """https://t.co/trAcIxBMlX""" + ) + + self.assertEquals(post.rule, profile) + self.assertEquals( + post.title, + truncate_text( + Post, + "title", + "@ArieNeoSC Here you go, goodnight!\n\nhttps://t.co/trAcIxBMlX", + ), + ) + self.assertEquals(post.body, mark_safe(full_text)) + + self.assertEquals(post.author, "RobertsSpaceInd") + self.assertEquals( + post.url, f"{TWITTER_URL}/RobertsSpaceInd/status/1291528756373286914" + ) + self.assertEquals( + post.publication_date, pytz.utc.localize(datetime(2020, 8, 7, 0, 17, 5)) + ) + + post = posts["1288550304095416320"] + + full_text = "@RelicCcb Hi Christoper, we have checked the status of your investigation and it is still ongoing." + + self.assertEquals(post.rule, profile) + self.assertEquals(post.title, truncate_text(Post, "title", full_text)) + self.assertEquals(post.body, mark_safe(full_text)) + + self.assertEquals(post.author, "RobertsSpaceInd") + self.assertEquals( + post.url, f"{TWITTER_URL}/RobertsSpaceInd/status/1288550304095416320" + ) + self.assertEquals( + post.publication_date, pytz.utc.localize(datetime(2020, 7, 29, 19, 1, 47)) + ) + + # note that only one media type can be uploaded to an Tweet + # see https://developer.twitter.com/en/docs/tweets/data-dictionary/overview/extended-entities-object + def test_images_in_post(self): + builder = TwitterBuilder + + profile = TwitterTimelineFactory(screen_name="RobertsSpaceInd") + mock_stream = Mock(rule=profile) + + with builder(image_mock, mock_stream) as builder: + builder.build() + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual(("1269039237166321664",), posts.keys()) + + post = posts["1269039237166321664"] + + self.assertEquals(post.rule, profile) + self.assertEquals(post.title, "_ https://t.co/VjEeDrL1iA") + + self.assertEquals(post.author, "RobertsSpaceInd") + self.assertEquals( + post.url, f"{TWITTER_URL}/RobertsSpaceInd/status/1269039237166321664" + ) + self.assertEquals( + post.publication_date, pytz.utc.localize(datetime(2020, 6, 5, 22, 51, 46)) + ) + + self.assertInHTML( + """https://t.co/VjEeDrL1iA""", + post.body, + count=1, + ) + self.assertInHTML( + """
      1269039233072689152
      """, + post.body, + count=1, + ) + self.assertInHTML( + """
      1269039233068527618
      """, + post.body, + count=1, + ) + + def test_videos_in_post(self): + builder = TwitterBuilder + + profile = TwitterTimelineFactory(screen_name="RobertsSpaceInd") + mock_stream = Mock(rule=profile) + + with builder(video_mock, mock_stream) as builder: + builder.build() + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual( + ("1291080532361527296", "1291079386821582849"), posts.keys() + ) + + post = posts["1291080532361527296"] + + full_text = fix_text( + "Small enough to access hard-to-reach ore deposits, but with enough" + " power to get through the tough jobs, Greycat\u2019s ROC perfectly" + " complements any mining operation. \n\nDetails:" + """ https://t.co/2aH7qdOfSk""" + """ https://t.co/mZ8CAuq3SH""" + ) + + self.assertEquals(post.rule, profile) + self.assertEquals( + post.title, + truncate_text( + Post, + "title", + fix_text( + "Small enough to access hard-to-reach ore deposits, but with enough" + " power to get through the tough jobs, Greycat\u2019s ROC perfectly" + " complements any mining operation. \n\nDetails:" + " https://t.co/2aH7qdOfSk https://t.co/mZ8CAuq3SH" + ), + ), + ) + + self.assertEquals(post.author, "RobertsSpaceInd") + self.assertEquals( + post.url, f"{TWITTER_URL}/RobertsSpaceInd/status/1291080532361527296" + ) + self.assertEquals( + post.publication_date, pytz.utc.localize(datetime(2020, 8, 5, 18, 36, 0)) + ) + + self.assertIn(full_text, post.body) + self.assertInHTML( + """
      """, + post.body, + count=1, + ) + + def test_video_without_bitrate(self): + builder = TwitterBuilder + + profile = TwitterTimelineFactory(screen_name="RobertsSpaceInd") + mock_stream = Mock(rule=profile) + + with builder(video_without_bitrate_mock, mock_stream) as builder: + builder.build() + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual(("1291080532361527296",), posts.keys()) + + post = posts["1291080532361527296"] + + self.assertInHTML( + """
      """, + post.body, + count=1, + ) + + def test_GIFs_in_post(self): + builder = TwitterBuilder + + profile = TwitterTimelineFactory(screen_name="RobertsSpaceInd") + mock_stream = Mock(rule=profile) + + with builder(gif_mock, mock_stream) as builder: + builder.build() + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual( + ("1289337776140296193", "1288965215648849920"), posts.keys() + ) + + post = posts["1289337776140296193"] + + self.assertInHTML( + """
      """, + post.body, + count=1, + ) + + self.assertIn( + """@Xenosystems https://t.co/wxvioLCJ6h""", + post.body, + ) + + def test_retweet_post(self): + builder = TwitterBuilder + + profile = TwitterTimelineFactory(screen_name="RobertsSpaceInd") + mock_stream = Mock(rule=profile) + + with builder(retweet_mock, mock_stream) as builder: + builder.build() + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual( + ("1291117030486106112", "1288825524878336000"), posts.keys() + ) + + post = posts["1291117030486106112"] + + self.assertIn( + fix_text( + "RT @Narayan_N7: New video! #StarCitizen 3.9 vs. 3.10 comparison!\nSo," + " the patch 3.10 came out, which brought us quite a lot of changes!\ud83d\ude42\nPle\u2026" + ), + post.body, + ) + + self.assertIn( + fix_text( + "Original tweet: New video! #StarCitizen 3.9 vs. 3.10 comparison!\nSo, the patch" + " 3.10 came out, which brought us quite a lot of changes!\ud83d\ude42\nPlease," + " share it with your friends!\ud83d\ude4f\n\nEnjoy watching and stay safe!" + " \u2764\ufe0f\u263a\ufe0f\n@RobertsSpaceInd\n\n@CloudImperium\n\n" + """https://t.co/j4QahHzbw4""" + ), + post.body, + ) + + def test_quoted_post(self): + builder = TwitterBuilder + + profile = TwitterTimelineFactory(screen_name="RobertsSpaceInd") + mock_stream = Mock(rule=profile) + + with builder(quoted_mock, mock_stream) as builder: + builder.build() + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual( + ("1290801039075979264", "1289320160021495809"), posts.keys() + ) + + post = posts["1290801039075979264"] + + self.assertIn( + fix_text( + "Bonne nuit \ud83c\udf3a\ud83d\udeeb" + """ https://t.co/WyznJwCJLp""" + ), + post.body, + ) + + self.assertIn( + fix_text( + "Quoted tweet: #Starcitizen Le jeu est beau. Bonne nuit" + """ @RobertsSpaceInd https://t.co/xCXun68V3r""" + ), + post.body, + ) + + def test_empty_data(self): + builder = TwitterBuilder + + profile = TwitterTimelineFactory(screen_name="RobertsSpaceInd") + mock_stream = Mock(rule=profile) + + with builder([], mock_stream) as builder: + builder.build() + builder.save() + + self.assertEquals(Post.objects.count(), 0) + + def test_html_sanitizing(self): + builder = TwitterBuilder + + profile = TwitterTimelineFactory(screen_name="RobertsSpaceInd") + mock_stream = Mock(rule=profile) + + with builder(unsanitized_mock, mock_stream) as builder: + builder.build() + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual(("1291528756373286914",), posts.keys()) + + post = posts["1291528756373286914"] + + full_text = ( + "@ArieNeoSC Here you go, goodnight!\n\n" + """https://t.co/trAcIxBMlX""" + "
      " + ) + + self.assertEquals(post.rule, profile) + self.assertEquals( + post.title, + truncate_text( + Post, + "title", + "@ArieNeoSC Here you go, goodnight!\n\nhttps://t.co/trAcIxBMlX" + "
      ", + ), + ) + self.assertEquals(post.body, mark_safe(full_text)) + + self.assertInHTML("", post.body, count=0) + self.assertInHTML("
      ", post.body, count=1) + + self.assertInHTML("", post.title, count=0) + self.assertInHTML("
      ", post.title, count=1) + + def test_urlize_on_urls(self): + builder = TwitterBuilder + + profile = TwitterTimelineFactory(screen_name="RobertsSpaceInd") + mock_stream = Mock(rule=profile) + + with builder(simple_mock, mock_stream) as builder: + builder.build() + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual( + ("1291528756373286914", "1288550304095416320"), posts.keys() + ) + + post = posts["1291528756373286914"] + + full_text = ( + "@ArieNeoSC Here you go, goodnight!\n\n" + """https://t.co/trAcIxBMlX""" + ) + + self.assertEquals(post.rule, profile) + self.assertEquals( + post.title, + truncate_text( + Post, + "title", + "@ArieNeoSC Here you go, goodnight!\n\nhttps://t.co/trAcIxBMlX", + ), + ) + self.assertEquals(post.body, mark_safe(full_text)) + + def test_existing_posts(self): + builder = TwitterBuilder + + profile = TwitterTimelineFactory(screen_name="RobertsSpaceInd") + mock_stream = Mock(rule=profile) + + PostFactory(rule=profile, remote_identifier="1291528756373286914") + PostFactory(rule=profile, remote_identifier="1288550304095416320") + + with builder(simple_mock, mock_stream) as builder: + builder.build() + builder.save() + + self.assertEquals(Post.objects.count(), 2) diff --git a/src/newsreader/news/collection/tests/twitter/client/__init__.py b/src/newsreader/news/collection/tests/twitter/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/twitter/client/mocks.py b/src/newsreader/news/collection/tests/twitter/client/mocks.py new file mode 100644 index 0000000..1b7c6a2 --- /dev/null +++ b/src/newsreader/news/collection/tests/twitter/client/mocks.py @@ -0,0 +1,225 @@ +# retrieved with: +# curl -X GET -H "Authorization: Bearer " "https://api.twitter.com/1.1/statuses/user_timeline.json?screen_name=RobertsSpaceInd&tweet_mode=extended" | python3 -m json.tool --sort-keys + +simple_mock = [ + { + "contributors": None, + "coordinates": None, + "created_at": "Fri Sep 18 20:32:22 +0000 2020", + "display_text_range": [0, 111], + "entities": { + "hashtags": [{"indices": [26, 41], "text": "SCShipShowdown"}], + "symbols": [], + "urls": [], + "user_mentions": [], + }, + "favorite_count": 54, + "favorited": False, + "full_text": "It's a close match-up for #SCShipShowdown today! Which Aegis ship do you think will make it to the Semi-Finals?", + "geo": None, + "id": 1307054882210435074, + "id_str": "1307054882210435074", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "retweet_count": 9, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4831, + "follow_request_sent": None, + "followers_count": 106971, + "following": None, + "friends_count": 204, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 893, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6368, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, + { + "contributors": None, + "coordinates": None, + "created_at": "Fri Sep 18 18:50:11 +0000 2020", + "display_text_range": [0, 271], + "entities": { + "hashtags": [{"indices": [211, 218], "text": "Twitch"}], + "media": [ + { + "display_url": "pic.twitter.com/Cey5JpR1i9", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1307029168941461504/photo/1", + "id": 1307028141697765376, + "id_str": "1307028141697765376", + "indices": [272, 295], + "media_url": "http://pbs.twimg.com/media/EiN_K4FVkAAGBcr.jpg", + "media_url_https": "https://pbs.twimg.com/media/EiN_K4FVkAAGBcr.jpg", + "sizes": { + "large": {"h": 1090, "resize": "fit", "w": 1920}, + "medium": {"h": 681, "resize": "fit", "w": 1200}, + "small": {"h": 386, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/Cey5JpR1i9", + } + ], + "symbols": [], + "urls": [ + { + "display_url": "twitch.tv/starcitizen", + "expanded_url": "http://twitch.tv/starcitizen", + "indices": [248, 271], + "url": "https://t.co/2AdNovhpFW", + } + ], + "user_mentions": [], + }, + "extended_entities": { + "media": [ + { + "display_url": "pic.twitter.com/Cey5JpR1i9", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1307029168941461504/photo/1", + "id": 1307028141697765376, + "id_str": "1307028141697765376", + "indices": [272, 295], + "media_url": "http://pbs.twimg.com/media/EiN_K4FVkAAGBcr.jpg", + "media_url_https": "https://pbs.twimg.com/media/EiN_K4FVkAAGBcr.jpg", + "sizes": { + "large": {"h": 1090, "resize": "fit", "w": 1920}, + "medium": {"h": 681, "resize": "fit", "w": 1200}, + "small": {"h": 386, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/Cey5JpR1i9", + } + ] + }, + "favorite_count": 90, + "favorited": False, + "full_text": "We\u2019re welcoming members of our Builds, Publishes and Platform teams on Star Citizen Live to talk about the process involved in bringing everyone\u2019s work together and getting it out into your hands. Going live on #Twitch in 10 minutes. \ud83c\udfa5\ud83d\udd34 \n\nTune in: https://t.co/2AdNovhpFW https://t.co/Cey5JpR1i9", + "geo": None, + "id": 1307029168941461504, + "id_str": "1307029168941461504", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "possibly_sensitive": False, + "retweet_count": 13, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4831, + "follow_request_sent": None, + "followers_count": 106971, + "following": None, + "friends_count": 204, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 893, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6368, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, +] diff --git a/src/newsreader/news/collection/tests/twitter/client/tests.py b/src/newsreader/news/collection/tests/twitter/client/tests.py new file mode 100644 index 0000000..387ffef --- /dev/null +++ b/src/newsreader/news/collection/tests/twitter/client/tests.py @@ -0,0 +1,162 @@ +from unittest.mock import Mock, patch +from uuid import uuid4 + +from django.test import TestCase +from django.utils.lorem_ipsum import words + +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamException, + StreamNotFoundException, + StreamParseException, + StreamTimeOutException, + StreamTooManyException, +) +from newsreader.news.collection.tests.factories import TwitterTimelineFactory +from newsreader.news.collection.twitter import TwitterClient + +from .mocks import simple_mock + + +class TwitterClientTestCase(TestCase): + def setUp(self): + patched_read = patch("newsreader.news.collection.twitter.TwitterStream.read") + self.mocked_read = patched_read.start() + + def tearDown(self): + patch.stopall() + + def test_simple(self): + timeline = TwitterTimelineFactory() + mock_stream = Mock(rule=timeline) + + self.mocked_read.return_value = (simple_mock, mock_stream) + + with TwitterClient([timeline]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertEquals(data, simple_mock) + self.assertEquals(stream, mock_stream) + + self.mocked_read.assert_called() + + def test_client_catches_stream_exception(self): + timeline = TwitterTimelineFactory() + + self.mocked_read.side_effect = StreamException(message="Stream exception") + + with TwitterClient([timeline]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertIsNone(data) + self.assertIsNone(stream) + self.assertEquals(stream.rule.error, "Stream exception") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called() + + def test_client_catches_stream_not_found_exception(self): + timeline = TwitterTimelineFactory.create() + + self.mocked_read.side_effect = StreamNotFoundException( + message="Stream not found" + ) + + with TwitterClient([timeline]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertIsNone(data) + self.assertIsNone(stream) + self.assertEquals(stream.rule.error, "Stream not found") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called() + + def test_client_catches_stream_denied_exception(self): + user = UserFactory( + twitter_oauth_token=str(uuid4()), twitter_oauth_token_secret=str(uuid4()) + ) + timeline = TwitterTimelineFactory(user=user) + + self.mocked_read.side_effect = StreamDeniedException(message="Token expired") + + with TwitterClient([timeline]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertIsNone(data) + self.assertIsNone(stream) + self.assertEquals(stream.rule.error, "Token expired") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called() + + user.refresh_from_db() + timeline.refresh_from_db() + + self.assertIsNone(user.twitter_oauth_token) + self.assertIsNone(user.twitter_oauth_token_secret) + + def test_client_catches_stream_timed_out_exception(self): + timeline = TwitterTimelineFactory() + + self.mocked_read.side_effect = StreamTimeOutException( + message="Stream timed out" + ) + + with TwitterClient([timeline]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertIsNone(data) + self.assertIsNone(stream) + self.assertEquals(stream.rule.error, "Stream timed out") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called() + + def test_client_catches_stream_too_many_exception(self): + timeline = TwitterTimelineFactory() + + self.mocked_read.side_effect = StreamTooManyException + + with TwitterClient([timeline]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertIsNone(data) + self.assertIsNone(stream) + self.assertEquals(stream.rule.error, "Too many requests") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called() + + def test_client_catches_stream_parse_exception(self): + timeline = TwitterTimelineFactory() + + self.mocked_read.side_effect = StreamParseException( + message="Stream could not be parsed" + ) + + with TwitterClient([timeline]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertIsNone(data) + self.assertIsNone(stream) + self.assertEquals(stream.rule.error, "Stream could not be parsed") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called() + + def test_client_catches_long_exception_text(self): + timeline = TwitterTimelineFactory() + mock_stream = Mock(rule=timeline) + + self.mocked_read.side_effect = StreamParseException(message=words(1000)) + + with TwitterClient([timeline]) as client: + for data, stream in client: + self.assertIsNone(data) + self.assertIsNone(stream) + self.assertEquals(len(stream.rule.error), 1024) + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called() diff --git a/src/newsreader/news/collection/tests/twitter/collector/__init__.py b/src/newsreader/news/collection/tests/twitter/collector/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/twitter/collector/mocks.py b/src/newsreader/news/collection/tests/twitter/collector/mocks.py new file mode 100644 index 0000000..c57f9cf --- /dev/null +++ b/src/newsreader/news/collection/tests/twitter/collector/mocks.py @@ -0,0 +1,227 @@ +# retrieved with: +# curl -X GET -H "Authorization: Bearer " "https://api.twitter.com/1.1/statuses/user_timeline.json?screen_name=RobertsSpaceInd&tweet_mode=extended" | python3 -m json.tool --sort-keys + +simple_mock = [ + { + "contributors": None, + "coordinates": None, + "created_at": "Fri Sep 18 20:32:22 +0000 2020", + "display_text_range": [0, 111], + "entities": { + "hashtags": [{"indices": [26, 41], "text": "SCShipShowdown"}], + "symbols": [], + "urls": [], + "user_mentions": [], + }, + "favorite_count": 54, + "favorited": False, + "full_text": "It's a close match-up for #SCShipShowdown today! Which Aegis ship do you think will make it to the Semi-Finals?", + "geo": None, + "id": 1307054882210435074, + "id_str": "1307054882210435074", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "retweet_count": 9, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4831, + "follow_request_sent": None, + "followers_count": 106971, + "following": None, + "friends_count": 204, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 893, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6368, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, + { + "contributors": None, + "coordinates": None, + "created_at": "Fri Sep 18 18:50:11 +0000 2020", + "display_text_range": [0, 271], + "entities": { + "hashtags": [{"indices": [211, 218], "text": "Twitch"}], + "media": [ + { + "display_url": "pic.twitter.com/Cey5JpR1i9", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1307029168941461504/photo/1", + "id": 1307028141697765376, + "id_str": "1307028141697765376", + "indices": [272, 295], + "media_url": "http://pbs.twimg.com/media/EiN_K4FVkAAGBcr.jpg", + "media_url_https": "https://pbs.twimg.com/media/EiN_K4FVkAAGBcr.jpg", + "sizes": { + "large": {"h": 1090, "resize": "fit", "w": 1920}, + "medium": {"h": 681, "resize": "fit", "w": 1200}, + "small": {"h": 386, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/Cey5JpR1i9", + } + ], + "symbols": [], + "urls": [ + { + "display_url": "twitch.tv/starcitizen", + "expanded_url": "http://twitch.tv/starcitizen", + "indices": [248, 271], + "url": "https://t.co/2AdNovhpFW", + } + ], + "user_mentions": [], + }, + "extended_entities": { + "media": [ + { + "display_url": "pic.twitter.com/Cey5JpR1i9", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1307029168941461504/photo/1", + "id": 1307028141697765376, + "id_str": "1307028141697765376", + "indices": [272, 295], + "media_url": "http://pbs.twimg.com/media/EiN_K4FVkAAGBcr.jpg", + "media_url_https": "https://pbs.twimg.com/media/EiN_K4FVkAAGBcr.jpg", + "sizes": { + "large": {"h": 1090, "resize": "fit", "w": 1920}, + "medium": {"h": 681, "resize": "fit", "w": 1200}, + "small": {"h": 386, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/Cey5JpR1i9", + } + ] + }, + "favorite_count": 90, + "favorited": False, + "full_text": "We\u2019re welcoming members of our Builds, Publishes and Platform teams on Star Citizen Live to talk about the process involved in bringing everyone\u2019s work together and getting it out into your hands. Going live on #Twitch in 10 minutes. \ud83c\udfa5\ud83d\udd34 \n\nTune in: https://t.co/2AdNovhpFW https://t.co/Cey5JpR1i9", + "geo": None, + "id": 1307029168941461504, + "id_str": "1307029168941461504", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "possibly_sensitive": False, + "retweet_count": 13, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4831, + "follow_request_sent": None, + "followers_count": 106971, + "following": None, + "friends_count": 204, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 893, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6368, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, +] + +empty_mock = [] diff --git a/src/newsreader/news/collection/tests/twitter/collector/tests.py b/src/newsreader/news/collection/tests/twitter/collector/tests.py new file mode 100644 index 0000000..766e971 --- /dev/null +++ b/src/newsreader/news/collection/tests/twitter/collector/tests.py @@ -0,0 +1,180 @@ +from datetime import datetime +from unittest.mock import patch +from uuid import uuid4 + +from django.test import TestCase +from django.utils import timezone + +import pytz + +from freezegun import freeze_time +from ftfy import fix_text + +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamForbiddenException, + StreamNotFoundException, + StreamTimeOutException, +) +from newsreader.news.collection.tests.factories import TwitterTimelineFactory +from newsreader.news.collection.tests.twitter.collector.mocks import ( + empty_mock, + simple_mock, +) +from newsreader.news.collection.twitter import TWITTER_URL, TwitterCollector +from newsreader.news.collection.utils import truncate_text +from newsreader.news.core.models import Post + + +@freeze_time("2020-09-26 14:40:00") +class TwitterCollectorTestCase(TestCase): + def setUp(self): + patched_get = patch("newsreader.news.collection.twitter.fetch") + self.mocked_fetch = patched_get.start() + + patched_parse = patch("newsreader.news.collection.twitter.TwitterStream.parse") + self.mocked_parse = patched_parse.start() + + def tearDown(self): + patch.stopall() + + def test_simple_batch(self): + self.mocked_parse.return_value = simple_mock + + timeline = TwitterTimelineFactory( + user__twitter_oauth_token=str(uuid4()), + user__twitter_oauth_token_secret=str(uuid4()), + screen_name="RobertsSpaceInd", + enabled=True, + ) + + collector = TwitterCollector() + collector.collect(rules=[timeline]) + + self.assertCountEqual( + Post.objects.values_list("remote_identifier", flat=True), + ("1307054882210435074", "1307029168941461504"), + ) + + self.assertEquals(timeline.succeeded, True) + self.assertEquals(timeline.last_run, timezone.now()) + self.assertIsNone(timeline.error) + + post = Post.objects.get( + remote_identifier="1307054882210435074", + rule__type=RuleTypeChoices.twitter_timeline, + ) + + self.assertEquals( + post.publication_date, pytz.utc.localize(datetime(2020, 9, 18, 20, 32, 22)) + ) + + title = truncate_text( + Post, + "title", + "It's a close match-up for #SCShipShowdown today! Which Aegis ship" + " do you think will make it to the Semi-Finals?", + ) + + self.assertEquals(post.author, "RobertsSpaceInd") + self.assertEquals(post.title, title) + self.assertEquals( + post.url, f"{TWITTER_URL}/RobertsSpaceInd/status/1307054882210435074" + ) + + post = Post.objects.get( + remote_identifier="1307029168941461504", + rule__type=RuleTypeChoices.twitter_timeline, + ) + + self.assertEquals( + post.publication_date, pytz.utc.localize(datetime(2020, 9, 18, 18, 50, 11)) + ) + + body = fix_text( + "We\u2019re welcoming members of our Builds, Publishes and Platform" + " teams on Star Citizen Live to talk about the process involved in" + " bringing everyone\u2019s work together and getting it out into your" + " hands. Going live on #Twitch in 10 minutes." + " \ud83c\udfa5\ud83d\udd34 \n\nTune in:" + " https://t.co/2AdNovhpFW https://t.co/Cey5JpR1i9" + ) + + title = truncate_text(Post, "title", body) + + self.assertEquals(post.author, "RobertsSpaceInd") + self.assertEquals(post.title, title) + self.assertEquals( + post.url, f"{TWITTER_URL}/RobertsSpaceInd/status/1307029168941461504" + ) + + def test_empty_batch(self): + self.mocked_parse.return_value = empty_mock + + timeline = TwitterTimelineFactory() + + collector = TwitterCollector() + collector.collect(rules=[timeline]) + + self.assertEquals(Post.objects.count(), 0) + + self.assertEquals(timeline.succeeded, True) + self.assertEquals(timeline.last_run, timezone.now()) + self.assertIsNone(timeline.error) + + def test_not_found(self): + self.mocked_fetch.side_effect = StreamNotFoundException + + timeline = TwitterTimelineFactory() + + collector = TwitterCollector() + collector.collect(rules=[timeline]) + + self.assertEquals(Post.objects.count(), 0) + self.assertEquals(timeline.succeeded, False) + self.assertEquals(timeline.error, "Stream not found") + + def test_denied(self): + self.mocked_fetch.side_effect = StreamDeniedException + + timeline = TwitterTimelineFactory( + user__twitter_oauth_token=str(uuid4()), + user__twitter_oauth_token_secret=str(uuid4()), + ) + + collector = TwitterCollector() + collector.collect(rules=[timeline]) + + self.assertEquals(Post.objects.count(), 0) + self.assertEquals(timeline.succeeded, False) + self.assertEquals(timeline.error, "Stream does not have sufficient permissions") + + user = timeline.user + + self.assertIsNone(user.twitter_oauth_token) + self.assertIsNone(user.twitter_oauth_token_secret) + + def test_forbidden(self): + self.mocked_fetch.side_effect = StreamForbiddenException + + timeline = TwitterTimelineFactory() + + collector = TwitterCollector() + collector.collect(rules=[timeline]) + + self.assertEquals(Post.objects.count(), 0) + self.assertEquals(timeline.succeeded, False) + self.assertEquals(timeline.error, "Stream forbidden") + + def test_timed_out(self): + self.mocked_fetch.side_effect = StreamTimeOutException + + timeline = TwitterTimelineFactory() + + collector = TwitterCollector() + collector.collect(rules=[timeline]) + + self.assertEquals(Post.objects.count(), 0) + self.assertEquals(timeline.succeeded, False) + self.assertEquals(timeline.error, "Stream timed out") diff --git a/src/newsreader/news/collection/tests/twitter/stream/__init__.py b/src/newsreader/news/collection/tests/twitter/stream/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/twitter/stream/mocks.py b/src/newsreader/news/collection/tests/twitter/stream/mocks.py new file mode 100644 index 0000000..1b7c6a2 --- /dev/null +++ b/src/newsreader/news/collection/tests/twitter/stream/mocks.py @@ -0,0 +1,225 @@ +# retrieved with: +# curl -X GET -H "Authorization: Bearer " "https://api.twitter.com/1.1/statuses/user_timeline.json?screen_name=RobertsSpaceInd&tweet_mode=extended" | python3 -m json.tool --sort-keys + +simple_mock = [ + { + "contributors": None, + "coordinates": None, + "created_at": "Fri Sep 18 20:32:22 +0000 2020", + "display_text_range": [0, 111], + "entities": { + "hashtags": [{"indices": [26, 41], "text": "SCShipShowdown"}], + "symbols": [], + "urls": [], + "user_mentions": [], + }, + "favorite_count": 54, + "favorited": False, + "full_text": "It's a close match-up for #SCShipShowdown today! Which Aegis ship do you think will make it to the Semi-Finals?", + "geo": None, + "id": 1307054882210435074, + "id_str": "1307054882210435074", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "retweet_count": 9, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4831, + "follow_request_sent": None, + "followers_count": 106971, + "following": None, + "friends_count": 204, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 893, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6368, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, + { + "contributors": None, + "coordinates": None, + "created_at": "Fri Sep 18 18:50:11 +0000 2020", + "display_text_range": [0, 271], + "entities": { + "hashtags": [{"indices": [211, 218], "text": "Twitch"}], + "media": [ + { + "display_url": "pic.twitter.com/Cey5JpR1i9", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1307029168941461504/photo/1", + "id": 1307028141697765376, + "id_str": "1307028141697765376", + "indices": [272, 295], + "media_url": "http://pbs.twimg.com/media/EiN_K4FVkAAGBcr.jpg", + "media_url_https": "https://pbs.twimg.com/media/EiN_K4FVkAAGBcr.jpg", + "sizes": { + "large": {"h": 1090, "resize": "fit", "w": 1920}, + "medium": {"h": 681, "resize": "fit", "w": 1200}, + "small": {"h": 386, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/Cey5JpR1i9", + } + ], + "symbols": [], + "urls": [ + { + "display_url": "twitch.tv/starcitizen", + "expanded_url": "http://twitch.tv/starcitizen", + "indices": [248, 271], + "url": "https://t.co/2AdNovhpFW", + } + ], + "user_mentions": [], + }, + "extended_entities": { + "media": [ + { + "display_url": "pic.twitter.com/Cey5JpR1i9", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1307029168941461504/photo/1", + "id": 1307028141697765376, + "id_str": "1307028141697765376", + "indices": [272, 295], + "media_url": "http://pbs.twimg.com/media/EiN_K4FVkAAGBcr.jpg", + "media_url_https": "https://pbs.twimg.com/media/EiN_K4FVkAAGBcr.jpg", + "sizes": { + "large": {"h": 1090, "resize": "fit", "w": 1920}, + "medium": {"h": 681, "resize": "fit", "w": 1200}, + "small": {"h": 386, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/Cey5JpR1i9", + } + ] + }, + "favorite_count": 90, + "favorited": False, + "full_text": "We\u2019re welcoming members of our Builds, Publishes and Platform teams on Star Citizen Live to talk about the process involved in bringing everyone\u2019s work together and getting it out into your hands. Going live on #Twitch in 10 minutes. \ud83c\udfa5\ud83d\udd34 \n\nTune in: https://t.co/2AdNovhpFW https://t.co/Cey5JpR1i9", + "geo": None, + "id": 1307029168941461504, + "id_str": "1307029168941461504", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "possibly_sensitive": False, + "retweet_count": 13, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4831, + "follow_request_sent": None, + "followers_count": 106971, + "following": None, + "friends_count": 204, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 893, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6368, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, +] diff --git a/src/newsreader/news/collection/tests/twitter/stream/tests.py b/src/newsreader/news/collection/tests/twitter/stream/tests.py new file mode 100644 index 0000000..4edb639 --- /dev/null +++ b/src/newsreader/news/collection/tests/twitter/stream/tests.py @@ -0,0 +1,107 @@ +from json import JSONDecodeError +from unittest.mock import patch + +from django.test import TestCase + +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamException, + StreamForbiddenException, + StreamNotFoundException, + StreamParseException, + StreamTimeOutException, + StreamTooManyException, +) +from newsreader.news.collection.tests.factories import TwitterTimelineFactory +from newsreader.news.collection.tests.twitter.stream.mocks import simple_mock +from newsreader.news.collection.twitter import TwitterStream + + +class TwitterStreamTestCase(TestCase): + def setUp(self): + self.patched_fetch = patch("newsreader.news.collection.twitter.fetch") + self.mocked_fetch = self.patched_fetch.start() + + def tearDown(self): + patch.stopall() + + def test_simple_stream(self): + self.mocked_fetch.return_value.json.return_value = simple_mock + + timeline = TwitterTimelineFactory() + stream = TwitterStream(timeline) + + data, stream = stream.read() + + self.assertEquals(data, simple_mock) + self.assertEquals(stream, stream) + + self.mocked_fetch.assert_called() + + def test_stream_raises_exception(self): + self.mocked_fetch.side_effect = StreamException + + timeline = TwitterTimelineFactory() + stream = TwitterStream(timeline) + + with self.assertRaises(StreamException): + stream.read() + + self.mocked_fetch.assert_called() + + def test_stream_raises_denied_exception(self): + self.mocked_fetch.side_effect = StreamDeniedException + + timeline = TwitterTimelineFactory() + stream = TwitterStream(timeline) + + with self.assertRaises(StreamDeniedException): + stream.read() + + self.mocked_fetch.assert_called() + + def test_stream_raises_not_found_exception(self): + self.mocked_fetch.side_effect = StreamNotFoundException + + timeline = TwitterTimelineFactory() + stream = TwitterStream(timeline) + + with self.assertRaises(StreamNotFoundException): + stream.read() + + self.mocked_fetch.assert_called() + + def test_stream_raises_time_out_exception(self): + self.mocked_fetch.side_effect = StreamTimeOutException + + timeline = TwitterTimelineFactory() + stream = TwitterStream(timeline) + + with self.assertRaises(StreamTimeOutException): + stream.read() + + self.mocked_fetch.assert_called() + + def test_stream_raises_forbidden_exception(self): + self.mocked_fetch.side_effect = StreamForbiddenException + + timeline = TwitterTimelineFactory() + stream = TwitterStream(timeline) + + with self.assertRaises(StreamForbiddenException): + stream.read() + + self.mocked_fetch.assert_called() + + def test_stream_raises_parse_exception(self): + self.mocked_fetch.return_value.json.side_effect = JSONDecodeError( + "No json found", "{}", 5 + ) + + timeline = TwitterTimelineFactory() + stream = TwitterStream(timeline) + + with self.assertRaises(StreamParseException): + stream.read() + + self.mocked_fetch.assert_called() diff --git a/src/newsreader/news/collection/tests/twitter/test_scheduler.py b/src/newsreader/news/collection/tests/twitter/test_scheduler.py new file mode 100644 index 0000000..a3c2db8 --- /dev/null +++ b/src/newsreader/news/collection/tests/twitter/test_scheduler.py @@ -0,0 +1,63 @@ +from json import JSONDecodeError +from unittest.mock import patch + +from django.test import TestCase + +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.exceptions import StreamException +from newsreader.news.collection.twitter import TwitterTimeLineScheduler + + +class TwitterTimeLineSchedulerTestCase(TestCase): + def setUp(self): + patched_fetch = patch("newsreader.news.collection.twitter.fetch") + self.mocked_fetch = patched_fetch.start() + + def test_simple(self): + user = UserFactory(twitter_oauth_token="foo", twitter_oauth_token_secret="bar") + + self.mocked_fetch.return_value.json.return_value = { + "rate_limit_context": {"application": "dummykey"}, + "resources": { + "statuses": { + "/statuses/user_timeline": { + "limit": 1500, + "remaining": 1500, + "reset": 1601141386, + } + } + }, + } + + scheduler = TwitterTimeLineScheduler(user) + + self.assertEquals(scheduler.get_current_ratelimit(), 1500) + + def test_stream_exception(self): + user = UserFactory(twitter_oauth_token=None, twitter_oauth_token_secret=None) + + self.mocked_fetch.side_effect = StreamException + + scheduler = TwitterTimeLineScheduler(user) + + self.assertEquals(scheduler.get_current_ratelimit(), None) + + def test_json_decode_error(self): + user = UserFactory(twitter_oauth_token="foo", twitter_oauth_token_secret="bar") + + self.mocked_fetch.return_value.json.side_effect = JSONDecodeError( + "foo", "bar", 10 + ) + + scheduler = TwitterTimeLineScheduler(user) + + self.assertEquals(scheduler.get_current_ratelimit(), None) + + def test_unexpected_contents(self): + user = UserFactory(twitter_oauth_token="foo", twitter_oauth_token_secret="bar") + + self.mocked_fetch.return_value.json.return_value = {"foo": "bar"} + + scheduler = TwitterTimeLineScheduler(user) + + self.assertEquals(scheduler.get_current_ratelimit(), None) diff --git a/src/newsreader/news/collection/tests/utils/tests.py b/src/newsreader/news/collection/tests/utils/tests.py index 10013c3..e88d1bf 100644 --- a/src/newsreader/news/collection/tests/utils/tests.py +++ b/src/newsreader/news/collection/tests/utils/tests.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import Mock, patch from django.test import TestCase @@ -19,7 +19,7 @@ from newsreader.news.collection.utils import fetch, post class HelperFunctionTestCase: def test_simple(self): - self.mocked_method.return_value = MagicMock(status_code=200, content="content") + self.mocked_method.return_value = Mock(status_code=200, content="content") url = "https://www.bbc.co.uk/news" response = self.method(url) @@ -27,7 +27,7 @@ class HelperFunctionTestCase: self.assertEquals(response.content, "content") def test_raises_not_found(self): - self.mocked_method.return_value = MagicMock(status_code=404) + self.mocked_method.return_value = Mock(status_code=404) url = "https://www.bbc.co.uk/news" @@ -35,7 +35,7 @@ class HelperFunctionTestCase: self.method(url) def test_raises_denied(self): - self.mocked_method.return_value = MagicMock(status_code=401) + self.mocked_method.return_value = Mock(status_code=401) url = "https://www.bbc.co.uk/news" @@ -43,7 +43,7 @@ class HelperFunctionTestCase: self.method(url) def test_raises_forbidden(self): - self.mocked_method.return_value = MagicMock(status_code=403) + self.mocked_method.return_value = Mock(status_code=403) url = "https://www.bbc.co.uk/news" @@ -51,7 +51,7 @@ class HelperFunctionTestCase: self.method(url) def test_raises_timed_out(self): - self.mocked_method.return_value = MagicMock(status_code=408) + self.mocked_method.return_value = Mock(status_code=408) url = "https://www.bbc.co.uk/news" @@ -99,7 +99,7 @@ class HelperFunctionTestCase: self.method(url) def test_raises_stream_error_on_too_many_requests(self): - self.mocked_method.return_value = MagicMock(status_code=429) + self.mocked_method.return_value = Mock(status_code=429) url = "https://www.bbc.co.uk/news" diff --git a/src/newsreader/news/collection/tests/views/base.py b/src/newsreader/news/collection/tests/views/base.py index d7de171..17f232c 100644 --- a/src/newsreader/news/collection/tests/views/base.py +++ b/src/newsreader/news/collection/tests/views/base.py @@ -49,7 +49,7 @@ class CollectionRuleViewTestCase: timezone=other_rule.timezone, ) - other_url = reverse("news:collection:rule-update", args=[other_rule.pk]) + other_url = reverse("news:collection:feed-update", args=[other_rule.pk]) response = self.client.post(other_url, self.form_data) self.assertEquals(response.status_code, 404) diff --git a/src/newsreader/news/collection/tests/views/test_crud.py b/src/newsreader/news/collection/tests/views/test_crud.py index 61f6835..7da241d 100644 --- a/src/newsreader/news/collection/tests/views/test_crud.py +++ b/src/newsreader/news/collection/tests/views/test_crud.py @@ -3,6 +3,8 @@ from django.urls import reverse import pytz +from django_celery_beat.models import PeriodicTask + from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.models import CollectionRule from newsreader.news.collection.tests.factories import FeedFactory @@ -10,11 +12,11 @@ from newsreader.news.collection.tests.views.base import CollectionRuleViewTestCa from newsreader.news.core.tests.factories import CategoryFactory -class CollectionRuleCreateViewTestCase(CollectionRuleViewTestCase, TestCase): +class FeedCreateViewTestCase(CollectionRuleViewTestCase, TestCase): def setUp(self): super().setUp() - self.url = reverse("news:collection:rule-create") + self.url = reverse("news:collection:feed-create") self.form_data.update( name="new rule", @@ -37,15 +39,21 @@ class CollectionRuleCreateViewTestCase(CollectionRuleViewTestCase, TestCase): self.assertEquals(rule.category.pk, self.category.pk) self.assertEquals(rule.user.pk, self.user.pk) + self.assertTrue( + PeriodicTask.objects.get( + name=f"{self.user.email}-feed", task="FeedTask", enabled=True + ) + ) -class CollectionRuleUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): + +class FeedUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): def setUp(self): super().setUp() self.rule = FeedFactory( name="collection rule", user=self.user, category=self.category ) - self.url = reverse("news:collection:rule-update", kwargs={"pk": self.rule.pk}) + self.url = reverse("news:collection:feed-update", kwargs={"pk": self.rule.pk}) self.form_data.update( name=self.rule.name, @@ -94,7 +102,7 @@ class CollectionRuleUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): category=self.category, type=RuleTypeChoices.subreddit, ) - url = reverse("news:collection:rule-update", kwargs={"pk": rule.pk}) + url = reverse("news:collection:feed-update", kwargs={"pk": rule.pk}) response = self.client.get(url) diff --git a/src/newsreader/news/collection/tests/views/test_import_view.py b/src/newsreader/news/collection/tests/views/test_import_view.py index f4188e7..a1f0017 100644 --- a/src/newsreader/news/collection/tests/views/test_import_view.py +++ b/src/newsreader/news/collection/tests/views/test_import_view.py @@ -84,7 +84,7 @@ class OPMLImportTestCase(TestCase): rules = CollectionRule.objects.all() self.assertEquals(len(rules), 0) - self.assertFormError(response, "form", "file", _("No (new) rules found")) + self.assertFormError(response, "form", "file", _("No (new) feeds found")) def test_invalid_feeds(self): file_path = self._get_file_path("invalid-url-feeds.opml") @@ -99,7 +99,7 @@ class OPMLImportTestCase(TestCase): rules = CollectionRule.objects.all() self.assertEquals(len(rules), 0) - self.assertFormError(response, "form", "file", _("No (new) rules found")) + self.assertFormError(response, "form", "file", _("No (new) feeds found")) def test_invalid_file(self): file_path = self._get_file_path("test.png") diff --git a/src/newsreader/news/collection/tests/views/test_twitter_views.py b/src/newsreader/news/collection/tests/views/test_twitter_views.py new file mode 100644 index 0000000..d9afa26 --- /dev/null +++ b/src/newsreader/news/collection/tests/views/test_twitter_views.py @@ -0,0 +1,129 @@ +from django.test import TestCase +from django.urls import reverse + +import pytz + +from django_celery_beat.models import PeriodicTask + +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.tests.factories import TwitterTimelineFactory +from newsreader.news.collection.tests.views.base import CollectionRuleViewTestCase +from newsreader.news.collection.twitter import TWITTER_API_URL +from newsreader.news.core.tests.factories import CategoryFactory + + +class TwitterTimelineCreateViewTestCase(CollectionRuleViewTestCase, TestCase): + def setUp(self): + super().setUp() + + self.form_data = { + "name": "new rule", + "screen_name": "RobertsSpaceInd", + "category": str(self.category.pk), + } + + self.url = reverse("news:collection:twitter-timeline-create") + + def test_creation(self): + response = self.client.post(self.url, self.form_data) + + self.assertEquals(response.status_code, 302) + + rule = CollectionRule.objects.get(name="new rule") + + self.assertEquals(rule.type, RuleTypeChoices.twitter_timeline) + self.assertEquals( + rule.url, + f"{TWITTER_API_URL}/statuses/user_timeline.json?screen_name=RobertsSpaceInd&tweet_mode=extended", + ) + self.assertEquals(rule.timezone, str(pytz.utc)) + self.assertEquals(rule.favicon, None) + self.assertEquals(rule.category.pk, self.category.pk) + self.assertEquals(rule.user.pk, self.user.pk) + + self.assertTrue( + PeriodicTask.objects.get( + name=f"{self.user.email}-timeline", + task="TwitterTimelineTask", + enabled=True, + ) + ) + + +class TwitterTimelineUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): + def setUp(self): + super().setUp() + + self.rule = TwitterTimelineFactory( + name="Star citizen", + screen_name="RobertsSpaceInd", + user=self.user, + category=self.category, + type=RuleTypeChoices.twitter_timeline, + ) + self.url = reverse( + "news:collection:twitter-timeline-update", kwargs={"pk": self.rule.pk} + ) + + self.form_data = { + "name": self.rule.name, + "screen_name": self.rule.screen_name, + "category": str(self.category.pk), + "timezone": pytz.utc, + } + + def test_name_change(self): + self.form_data.update(name="Star citizen Twitter") + + response = self.client.post(self.url, self.form_data) + self.assertEquals(response.status_code, 302) + + self.rule.refresh_from_db() + + self.assertEquals(self.rule.name, "Star citizen Twitter") + + def test_category_change(self): + new_category = CategoryFactory(user=self.user) + + self.form_data.update(category=new_category.pk) + + response = self.client.post(self.url, self.form_data) + self.assertEquals(response.status_code, 302) + + self.rule.refresh_from_db() + + self.assertEquals(self.rule.category.pk, new_category.pk) + + def test_twitter_timelines_only(self): + rule = TwitterTimelineFactory( + name="Fake twitter", + user=self.user, + category=self.category, + type=RuleTypeChoices.feed, + url="https://twitter.com/RobertsSpaceInd", + ) + url = reverse("news:collection:twitter-timeline-update", kwargs={"pk": rule.pk}) + + response = self.client.get(url) + + self.assertEquals(response.status_code, 404) + + def test_screen_name_change(self): + self.form_data.update(screen_name="CyberpunkGame") + + response = self.client.post(self.url, self.form_data) + + self.assertEquals(response.status_code, 302) + + self.rule.refresh_from_db() + + self.assertEquals(self.rule.type, RuleTypeChoices.twitter_timeline) + self.assertEquals( + self.rule.url, + f"{TWITTER_API_URL}/statuses/user_timeline.json?screen_name=CyberpunkGame&tweet_mode=extended", + ) + self.assertEquals(self.rule.timezone, str(pytz.utc)) + self.assertEquals(self.rule.favicon, None) + self.assertEquals(self.rule.category.pk, self.category.pk) + self.assertEquals(self.rule.user.pk, self.user.pk) diff --git a/src/newsreader/news/collection/twitter.py b/src/newsreader/news/collection/twitter.py new file mode 100644 index 0000000..dc32ecc --- /dev/null +++ b/src/newsreader/news/collection/twitter.py @@ -0,0 +1,281 @@ +import logging + +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime +from json import JSONDecodeError + +from django.conf import settings +from django.utils import timezone +from django.utils.html import format_html, urlize + +import pytz + +from ftfy import fix_text +from requests_oauthlib import OAuth1 as OAuth + +from newsreader.news.collection.base import ( + PostBuilder, + PostClient, + PostCollector, + PostStream, + Scheduler, +) +from newsreader.news.collection.choices import RuleTypeChoices, TwitterPostTypeChoices +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamException, + StreamNotFoundException, + StreamParseException, + StreamTimeOutException, + StreamTooManyException, +) +from newsreader.news.collection.utils import fetch, truncate_text +from newsreader.news.core.models import Post + + +logger = logging.getLogger(__name__) + +TWITTER_URL = "https://twitter.com" +TWITTER_API_URL = "https://api.twitter.com/1.1" +TWITTER_REQUEST_TOKEN_URL = "https://api.twitter.com/oauth/request_token" +TWITTER_AUTH_URL = "https://api.twitter.com/oauth/authorize" +TWITTER_ACCESS_TOKEN_URL = "https://api.twitter.com/oauth/access_token" +TWITTER_REVOKE_URL = f"{TWITTER_API_URL}/oauth/invalidate_token" + + +class TwitterBuilder(PostBuilder): + rule_type = RuleTypeChoices.twitter_timeline + + def build(self): + results = {} + rule = self.stream.rule + + for post in self.payload: + remote_identifier = post["id_str"] + + if remote_identifier in self.existing_posts: + continue + + url = f"{TWITTER_URL}/{rule.screen_name}/status/{remote_identifier}" + body = urlize(post["full_text"], nofollow=True) + title = truncate_text( + Post, "title", self.sanitize_fragment(post["full_text"]) + ) + + publication_date = pytz.utc.localize( + datetime.strptime(post["created_at"], "%a %b %d %H:%M:%S +0000 %Y") + ) + + if "extended_entities" in post: + try: + media_entities = self.get_media_entities(post) + body += media_entities + except KeyError: + logger.exception(f"Failed parsing media_entities for {url}") + + if "retweeted_status" in post: + original_post = post["retweeted_status"] + original_tweet = urlize(original_post["full_text"], nofollow=True) + body = f"{body}
      Original tweet: {original_tweet}
      " + if "quoted_status" in post: + original_post = post["quoted_status"] + original_tweet = urlize(original_post["full_text"], nofollow=True) + body = f"{body}
      Quoted tweet: {original_tweet}
      " + + body = self.sanitize_fragment(body) + + data = { + "remote_identifier": remote_identifier, + "title": fix_text(title), + "body": fix_text(body), + "author": rule.screen_name, + "publication_date": publication_date, + "url": url, + "rule": rule, + } + + results[remote_identifier] = Post(**data) + + self.instances = results.values() + + def get_media_entities(self, post): + media_entities = post["extended_entities"]["media"] + formatted_entities = "" + + for media_entity in media_entities: + media_type = media_entity["type"] + media_url = media_entity["media_url_https"] + title = media_entity["id_str"] + + if media_type == TwitterPostTypeChoices.photo: + html_fragment = format_html( + """
      {title}
      """, + title=title, + media_url=media_url, + ) + + formatted_entities += html_fragment + + elif media_type in ( + TwitterPostTypeChoices.video, + TwitterPostTypeChoices.animated_gif, + ): + meta_data = media_entity["video_info"] + + videos = sorted( + [video for video in meta_data["variants"]], + reverse=True, + key=lambda video: video.get("bitrate", 0), + ) + + if not videos: + continue + + video = videos[0] + content_type = video["content_type"] + url = video["url"] + + html_fragment = format_html( + """
      """, + url=url, + content_type=content_type, + ) + + formatted_entities += html_fragment + + return formatted_entities + + +class TwitterStream(PostStream): + rule_type = RuleTypeChoices.twitter_timeline + + def read(self): + oauth = OAuth( + settings.TWITTER_CONSUMER_ID, + client_secret=settings.TWITTER_CONSUMER_SECRET, + resource_owner_key=self.rule.user.twitter_oauth_token, + resource_owner_secret=self.rule.user.twitter_oauth_token_secret, + ) + + response = fetch(self.rule.url, auth=oauth) + + return self.parse(response), self + + def parse(self, response): + try: + return response.json() + except JSONDecodeError as e: + raise StreamParseException( + response=response, message="Failed parsing json" + ) from e + + +class TwitterClient(PostClient): + stream = TwitterStream + + def __enter__(self): + streams = [self.stream(timeline) for timeline in self.rules] + + with ThreadPoolExecutor(max_workers=10) as executor: + futures = {executor.submit(stream.read): stream for stream in streams} + + for future in as_completed(futures): + stream = futures[future] + + try: + payload = future.result() + + stream.rule.error = None + stream.rule.succeeded = True + + yield payload + except StreamTooManyException as e: + logger.exception("Ratelimit hit, aborting twitter calls") + + self.set_rule_error(stream.rule, e) + + break + except StreamDeniedException as e: + logger.warning( + f"Access token expired for user {stream.rule.user.pk}" + ) + + stream.rule.user.twitter_oauth_token = None + stream.rule.user.twitter_oauth_token_secret = None + stream.rule.user.save() + + self.set_rule_error(stream.rule, e) + + break + except (StreamNotFoundException, StreamTimeOutException) as e: + logger.warning(f"Request failed for {stream.rule.screen_name}") + + self.set_rule_error(stream.rule, e) + + continue + except StreamException as e: + logger.exception(f"Request failed for {stream.rule.screen_name}") + + self.set_rule_error(stream.rule, e) + + continue + finally: + stream.rule.last_run = timezone.now() + stream.rule.save() + + +class TwitterCollector(PostCollector): + builder = TwitterBuilder + client = TwitterClient + + +# see https://developer.twitter.com/en/docs/twitter-api/v1/rate-limits +class TwitterTimeLineScheduler(Scheduler): + def __init__(self, user, timelines=[]): + self.user = user + + if not timelines: + self.timelines = ( + user.rules.enabled() + .filter(type=RuleTypeChoices.twitter_timeline) + .order_by("last_run")[:200] + ) + else: + self.timelines = timelines + + def get_scheduled_rules(self): + max_amount = self.get_current_ratelimit() + return self.timelines[:max_amount] if max_amount else [] + + def get_current_ratelimit(self): + endpoint = "application/rate_limit_status.json?resources=statuses" + + if ( + not self.user.twitter_oauth_token + or not self.user.twitter_oauth_token_secret + ): + return + + oauth = OAuth( + settings.TWITTER_CONSUMER_ID, + client_secret=settings.TWITTER_CONSUMER_SECRET, + resource_owner_key=self.user.twitter_oauth_token, + resource_owner_secret=self.user.twitter_oauth_token_secret, + ) + + try: + response = fetch(f"{TWITTER_API_URL}/{endpoint}", auth=oauth) + except StreamException: + logger.exception(f"Unable to retrieve current ratelimit for {self.user.pk}") + return + + try: + payload = response.json() + except JSONDecodeError: + logger.exception(f"Unable to parse ratelimit request for {self.user.pk}") + return + + try: + return payload["resources"]["statuses"]["/statuses/user_timeline"]["limit"] + except KeyError: + return diff --git a/src/newsreader/news/collection/urls.py b/src/newsreader/news/collection/urls.py index 5253210..7d883f2 100644 --- a/src/newsreader/news/collection/urls.py +++ b/src/newsreader/news/collection/urls.py @@ -11,12 +11,14 @@ from newsreader.news.collection.views import ( CollectionRuleBulkDeleteView, CollectionRuleBulkDisableView, CollectionRuleBulkEnableView, - CollectionRuleCreateView, CollectionRuleListView, - CollectionRuleUpdateView, + FeedCreateView, + FeedUpdateView, OPMLImportView, SubRedditCreateView, SubRedditUpdateView, + TwitterTimelineCreateView, + TwitterTimelineUpdateView, ) @@ -28,17 +30,13 @@ endpoints = [ ] urlpatterns = [ + # Feeds + path( + "feeds//", login_required(FeedUpdateView.as_view()), name="feed-update" + ), + path("feeds/create/", login_required(FeedCreateView.as_view()), name="feed-create"), + # Generic rules path("rules/", login_required(CollectionRuleListView.as_view()), name="rules"), - path( - "rules//", - login_required(CollectionRuleUpdateView.as_view()), - name="rule-update", - ), - path( - "rules/create/", - login_required(CollectionRuleCreateView.as_view()), - name="rule-create", - ), path( "rules/delete/", login_required(CollectionRuleBulkDeleteView.as_view()), @@ -54,15 +52,27 @@ urlpatterns = [ login_required(CollectionRuleBulkDisableView.as_view()), name="rules-disable", ), + path("rules/import/", login_required(OPMLImportView.as_view()), name="import"), + # Reddit path( - "rules/subreddits/create/", + "subreddits/create/", login_required(SubRedditCreateView.as_view()), name="subreddit-create", ), path( - "rules/subreddits//", + "subreddits//", login_required(SubRedditUpdateView.as_view()), name="subreddit-update", ), - path("rules/import/", login_required(OPMLImportView.as_view()), name="import"), + # Twitter + path( + "twitter/timelines/create/", + login_required(TwitterTimelineCreateView.as_view()), + name="twitter-timeline-create", + ), + path( + "twitter/timelines//", + login_required(TwitterTimelineUpdateView.as_view()), + name="twitter-timeline-update", + ), ] diff --git a/src/newsreader/news/collection/utils.py b/src/newsreader/news/collection/utils.py index 4cfc0e7..0eb1dc0 100644 --- a/src/newsreader/news/collection/utils.py +++ b/src/newsreader/news/collection/utils.py @@ -25,12 +25,12 @@ def build_publication_date(dt, tz): return published_parsed.astimezone(pytz.utc) -def fetch(url, headers={}): +def fetch(url, auth=None, headers={}): headers = {**DEFAULT_HEADERS, **headers} with ResponseHandler() as response_handler: try: - response = requests.get(url, headers=headers) + response = requests.get(url, auth=auth, headers=headers) response_handler.handle_response(response) except RequestException as exception: response_handler.map_exception(exception) diff --git a/src/newsreader/news/collection/views/__init__.py b/src/newsreader/news/collection/views/__init__.py index 20769f3..c66c5a5 100644 --- a/src/newsreader/news/collection/views/__init__.py +++ b/src/newsreader/news/collection/views/__init__.py @@ -1,3 +1,8 @@ +from newsreader.news.collection.views.feed import ( + FeedCreateView, + FeedUpdateView, + OPMLImportView, +) from newsreader.news.collection.views.reddit import ( SubRedditCreateView, SubRedditUpdateView, @@ -6,8 +11,9 @@ from newsreader.news.collection.views.rules import ( CollectionRuleBulkDeleteView, CollectionRuleBulkDisableView, CollectionRuleBulkEnableView, - CollectionRuleCreateView, CollectionRuleListView, - CollectionRuleUpdateView, - OPMLImportView, +) +from newsreader.news.collection.views.twitter import ( + TwitterTimelineCreateView, + TwitterTimelineUpdateView, ) diff --git a/src/newsreader/news/collection/views/base.py b/src/newsreader/news/collection/views/base.py index e7f7b63..d7a3a4d 100644 --- a/src/newsreader/news/collection/views/base.py +++ b/src/newsreader/news/collection/views/base.py @@ -1,8 +1,11 @@ +import json + from django.urls import reverse_lazy import pytz -from newsreader.news.collection.forms import CollectionRuleForm +from django_celery_beat.models import IntervalSchedule, PeriodicTask + from newsreader.news.collection.models import CollectionRule from newsreader.news.core.models import Category @@ -17,7 +20,6 @@ class CollectionRuleViewMixin: class CollectionRuleDetailMixin: success_url = reverse_lazy("news:collection:rules") - form_class = CollectionRuleForm def get_context_data(self, **kwargs): context_data = super().get_context_data(**kwargs) @@ -34,3 +36,25 @@ class CollectionRuleDetailMixin: kwargs = super().get_form_kwargs() kwargs["user"] = self.request.user return kwargs + + +class TaskCreationMixin: + def form_valid(self, form): + response = super().form_valid(form) + + interval, period = self.task_interval + task_interval, _ = IntervalSchedule.objects.get_or_create( + every=interval, period=period + ) + + PeriodicTask.objects.get_or_create( + name=f"{self.request.user.email}-{self.task_name}", + task=self.task_type, + defaults={ + "args": json.dumps([self.request.user.pk]), + "interval": task_interval, + "enabled": True, + }, + ) + + return response diff --git a/src/newsreader/news/collection/views/feed.py b/src/newsreader/news/collection/views/feed.py new file mode 100644 index 0000000..b7803d2 --- /dev/null +++ b/src/newsreader/news/collection/views/feed.py @@ -0,0 +1,70 @@ +from django.contrib import messages +from django.urls import reverse +from django.utils.translation import gettext as _ +from django.views.generic.edit import CreateView, FormView, UpdateView + +from django_celery_beat.models import IntervalSchedule + +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.forms import ( + CollectionRuleBulkForm, + FeedForm, + OPMLImportForm, +) +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.views.base import ( + CollectionRuleDetailMixin, + CollectionRuleViewMixin, + TaskCreationMixin, +) +from newsreader.utils.opml import parse_opml + + +class FeedUpdateView(CollectionRuleViewMixin, CollectionRuleDetailMixin, UpdateView): + template_name = "news/collection/views/feed-update.html" + context_object_name = "feed" + form_class = FeedForm + + def get_queryset(self): + queryset = super().get_queryset() + return queryset.filter(type=RuleTypeChoices.feed) + + +class FeedCreateView( + CollectionRuleViewMixin, CollectionRuleDetailMixin, TaskCreationMixin, CreateView +): + template_name = "news/collection/views/feed-create.html" + task_interval = (1, IntervalSchedule.HOURS) + task_name = "feed" + task_type = "FeedTask" + form_class = FeedForm + + +class OPMLImportView(FormView): + form_class = OPMLImportForm + template_name = "news/collection/views/import.html" + + def form_valid(self, form): + user = self.request.user + file = form.cleaned_data["file"] + skip_existing = form.cleaned_data["skip_existing"] + + instances = parse_opml(file, user, skip_existing=skip_existing) + + try: + feeds = CollectionRule.objects.bulk_create(instances) + except IOError: + form.add_error("file", _("Invalid OPML file")) + return self.form_invalid(form) + + if not feeds: + form.add_error("file", _("No (new) feeds found")) + return self.form_invalid(form) + + message = _(f"{len(feeds)} new feeds created") + messages.success(self.request, message) + + return super().form_valid(form) + + def get_success_url(self): + return reverse("news:collection:rules") diff --git a/src/newsreader/news/collection/views/reddit.py b/src/newsreader/news/collection/views/reddit.py index 62ec408..4e44e3f 100644 --- a/src/newsreader/news/collection/views/reddit.py +++ b/src/newsreader/news/collection/views/reddit.py @@ -1,7 +1,7 @@ from django.views.generic.edit import CreateView, UpdateView from newsreader.news.collection.choices import RuleTypeChoices -from newsreader.news.collection.forms import SubRedditRuleForm +from newsreader.news.collection.forms import SubRedditForm from newsreader.news.collection.views.base import ( CollectionRuleDetailMixin, CollectionRuleViewMixin, @@ -11,14 +11,14 @@ from newsreader.news.collection.views.base import ( class SubRedditCreateView( CollectionRuleViewMixin, CollectionRuleDetailMixin, CreateView ): - form_class = SubRedditRuleForm + form_class = SubRedditForm template_name = "news/collection/views/subreddit-create.html" class SubRedditUpdateView( CollectionRuleViewMixin, CollectionRuleDetailMixin, UpdateView ): - form_class = SubRedditRuleForm + form_class = SubRedditForm template_name = "news/collection/views/subreddit-update.html" context_object_name = "subreddit" diff --git a/src/newsreader/news/collection/views/rules.py b/src/newsreader/news/collection/views/rules.py index e020b67..902eedf 100644 --- a/src/newsreader/news/collection/views/rules.py +++ b/src/newsreader/news/collection/views/rules.py @@ -2,17 +2,14 @@ from django.contrib import messages from django.shortcuts import redirect from django.urls import reverse from django.utils.translation import gettext as _ -from django.views.generic.edit import CreateView, FormView, UpdateView +from django.views.generic.edit import FormView from django.views.generic.list import ListView -from newsreader.news.collection.choices import RuleTypeChoices -from newsreader.news.collection.forms import CollectionRuleBulkForm, OPMLImportForm -from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.forms import CollectionRuleBulkForm from newsreader.news.collection.views.base import ( CollectionRuleDetailMixin, CollectionRuleViewMixin, ) -from newsreader.utils.opml import parse_opml class CollectionRuleListView(CollectionRuleViewMixin, ListView): @@ -21,23 +18,6 @@ class CollectionRuleListView(CollectionRuleViewMixin, ListView): context_object_name = "rules" -class CollectionRuleUpdateView( - CollectionRuleViewMixin, CollectionRuleDetailMixin, UpdateView -): - template_name = "news/collection/views/rule-update.html" - context_object_name = "rule" - - def get_queryset(self): - queryset = super().get_queryset() - return queryset.filter(type=RuleTypeChoices.feed) - - -class CollectionRuleCreateView( - CollectionRuleViewMixin, CollectionRuleDetailMixin, CreateView -): - template_name = "news/collection/views/rule-create.html" - - class CollectionRuleBulkView(FormView): form_class = CollectionRuleBulkForm @@ -90,33 +70,3 @@ class CollectionRuleBulkDeleteView(CollectionRuleBulkView): rule.delete() return response - - -class OPMLImportView(FormView): - form_class = OPMLImportForm - template_name = "news/collection/views/import.html" - - def form_valid(self, form): - user = self.request.user - file = form.cleaned_data["file"] - skip_existing = form.cleaned_data["skip_existing"] - - instances = parse_opml(file, user, skip_existing=skip_existing) - - try: - rules = CollectionRule.objects.bulk_create(instances) - except IOError: - form.add_error("file", _("Invalid OPML file")) - return self.form_invalid(form) - - if not rules: - form.add_error("file", _("No (new) rules found")) - return self.form_invalid(form) - - message = _(f"{len(rules)} new rules created") - messages.success(self.request, message) - - return super().form_valid(form) - - def get_success_url(self): - return reverse("news:collection:rules") diff --git a/src/newsreader/news/collection/views/twitter.py b/src/newsreader/news/collection/views/twitter.py new file mode 100644 index 0000000..0221a75 --- /dev/null +++ b/src/newsreader/news/collection/views/twitter.py @@ -0,0 +1,33 @@ +from django.views.generic.edit import CreateView, UpdateView + +from django_celery_beat.models import IntervalSchedule + +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.forms import TwitterTimelineForm +from newsreader.news.collection.views.base import ( + CollectionRuleDetailMixin, + CollectionRuleViewMixin, + TaskCreationMixin, +) + + +class TwitterTimelineCreateView( + CollectionRuleViewMixin, CollectionRuleDetailMixin, TaskCreationMixin, CreateView +): + form_class = TwitterTimelineForm + template_name = "news/collection/views/twitter/timeline-create.html" + task_interval = (10, IntervalSchedule.MINUTES) + task_name = "timeline" + task_type = "TwitterTimelineTask" + + +class TwitterTimelineUpdateView( + CollectionRuleViewMixin, CollectionRuleDetailMixin, UpdateView +): + form_class = TwitterTimelineForm + template_name = "news/collection/views/twitter/timeline-update.html" + context_object_name = "timeline" + + def get_queryset(self): + queryset = super().get_queryset() + return queryset.filter(type=RuleTypeChoices.twitter_timeline) diff --git a/src/newsreader/news/core/templates/news/core/views/categories.html b/src/newsreader/news/core/templates/news/core/views/categories.html index 35fc741..6a6cdae 100644 --- a/src/newsreader/news/core/templates/news/core/views/categories.html +++ b/src/newsreader/news/core/templates/news/core/views/categories.html @@ -30,5 +30,8 @@ ] + {{ categories_update_url|json_script:"updateUrl" }} + {{ categories_create_url|json_script:"createUrl" }} + {{ block.super }} {% endblock %} diff --git a/src/newsreader/news/core/templates/news/core/views/homepage.html b/src/newsreader/news/core/templates/news/core/views/homepage.html index 79e1ccc..502ef63 100644 --- a/src/newsreader/news/core/templates/news/core/views/homepage.html +++ b/src/newsreader/news/core/templates/news/core/views/homepage.html @@ -3,4 +3,13 @@ {% block content %}
      -{% endblock %} +{% endblock content %} + +{% block scripts %} + {{ feed_url|json_script:"feedUrl" }} + {{ subreddit_url|json_script:"subredditUrl" }} + {{ twitter_timeline_url|json_script:"timelineUrl" }} + {{ categories_url|json_script:"categoriesUrl" }} + + {{ block.super }} +{% endblock scripts %} diff --git a/src/newsreader/news/core/views.py b/src/newsreader/news/core/views.py index 9ef81eb..981e7b2 100644 --- a/src/newsreader/news/core/views.py +++ b/src/newsreader/news/core/views.py @@ -11,24 +11,21 @@ from newsreader.news.core.models import Category class NewsView(TemplateView): template_name = "news/core/views/homepage.html" - # TODO serialize objects to show filled main page def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - user = self.request.user - categories = { - category: category.rules.order_by("-created") - for category in user.categories.order_by("name") + return { + **context, + "feed_url": reverse_lazy("news:collection:feed-update", args=(0,)), + "subreddit_url": reverse_lazy( + "news:collection:subreddit-update", args=(0,) + ), + "twitter_timeline_url": reverse_lazy( + "news:collection:twitter-timeline-update", args=(0,) + ), + "categories_url": reverse_lazy("news:core:category-update", args=(0,)), } - rules = { - rule: rule.posts.order_by("-publication_date")[:30] - for rule in user.rules.order_by("-created") - } - - context.update(categories=categories, rules=rules) - return context - class CategoryViewMixin: queryset = Category.objects.prefetch_related("rules").order_by("name") @@ -58,6 +55,17 @@ class CategoryListView(CategoryViewMixin, ListView): template_name = "news/core/views/categories.html" context_object_name = "categories" + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + + return { + **context, + "categories_create_url": reverse_lazy("news:core:category-create"), + "categories_update_url": ( + reverse_lazy("news:core:category-update", args=(0,)) + ), + } + class CategoryUpdateView(CategoryViewMixin, CategoryDetailMixin, UpdateView): template_name = "news/core/views/category-update.html" diff --git a/src/newsreader/scss/components/header/_header.scss b/src/newsreader/scss/components/header/_header.scss new file mode 100644 index 0000000..ed96dc6 --- /dev/null +++ b/src/newsreader/scss/components/header/_header.scss @@ -0,0 +1,3 @@ +.header { + padding: 15px; +} diff --git a/src/newsreader/scss/components/header/index.scss b/src/newsreader/scss/components/header/index.scss new file mode 100644 index 0000000..5c23e3e --- /dev/null +++ b/src/newsreader/scss/components/header/index.scss @@ -0,0 +1 @@ +@import './header'; diff --git a/src/newsreader/scss/components/index.scss b/src/newsreader/scss/components/index.scss index cc9e717..b82a22d 100644 --- a/src/newsreader/scss/components/index.scss +++ b/src/newsreader/scss/components/index.scss @@ -8,6 +8,7 @@ @import './card/index'; @import './list/index'; +@import './header/index'; @import './messages/index'; @import './section/index'; @import './errorlist/index'; @@ -16,6 +17,8 @@ @import './sidebar/index'; @import './table/index'; +@import './integrations/index'; + @import './rules/index'; @import './category/index'; diff --git a/src/newsreader/scss/components/integrations/_integrations.scss b/src/newsreader/scss/components/integrations/_integrations.scss new file mode 100644 index 0000000..815184e --- /dev/null +++ b/src/newsreader/scss/components/integrations/_integrations.scss @@ -0,0 +1,12 @@ +.integrations { + display: flex; + flex-direction: column; + gap: 15px; + + padding: 15px; + + &__controls { + display: flex; + gap: 10px; + } +} diff --git a/src/newsreader/scss/components/integrations/index.scss b/src/newsreader/scss/components/integrations/index.scss new file mode 100644 index 0000000..7f9e759 --- /dev/null +++ b/src/newsreader/scss/components/integrations/index.scss @@ -0,0 +1 @@ +@import './integrations'; diff --git a/src/newsreader/scss/elements/button/_button.scss b/src/newsreader/scss/elements/button/_button.scss index a8eb3bc..7cd062a 100644 --- a/src/newsreader/scss/elements/button/_button.scss +++ b/src/newsreader/scss/elements/button/_button.scss @@ -44,10 +44,24 @@ &--reddit { color: $white !important; - background-color: lighten($reddit-orange, 5%); + background-color: $reddit-orange; &:hover { - background-color: $reddit-orange; + background-color: lighten($reddit-orange, 5%); } } + + &--twitter { + color: $white !important; + background-color: $twitter-blue; + + &:hover { + background-color: lighten($twitter-blue, 5%); + } + } + + &--disabled { + color: $font-color !important; + background-color: $gray !important; + } } diff --git a/src/newsreader/scss/pages/index.scss b/src/newsreader/scss/pages/index.scss index 44ca8a7..2ac0bb2 100644 --- a/src/newsreader/scss/pages/index.scss +++ b/src/newsreader/scss/pages/index.scss @@ -12,3 +12,4 @@ @import './rules/index'; @import './settings/index'; +@import './integrations/index'; diff --git a/src/newsreader/scss/pages/integrations/index.scss b/src/newsreader/scss/pages/integrations/index.scss new file mode 100644 index 0000000..ccf52c3 --- /dev/null +++ b/src/newsreader/scss/pages/integrations/index.scss @@ -0,0 +1,5 @@ +#integrations--page { + .section { + width: 70%; + } +} diff --git a/src/newsreader/scss/partials/_colors.scss b/src/newsreader/scss/partials/_colors.scss index b2f124d..87f6e49 100644 --- a/src/newsreader/scss/partials/_colors.scss +++ b/src/newsreader/scss/partials/_colors.scss @@ -12,6 +12,7 @@ $font-color: rgba(48, 51, 53, 1); $header-color: rgba(100, 101, 102, 1); $reddit-orange: rgba(255, 69, 0, 1); +$twitter-blue: rgba(29, 155, 240, 1); $transparant-red: transparentize($red, 0.8); $transparant-blue: transparentize($blue, 0.8); diff --git a/src/newsreader/templates/components/form/form.html b/src/newsreader/templates/components/form/form.html index e183c25..9f1ab47 100644 --- a/src/newsreader/templates/components/form/form.html +++ b/src/newsreader/templates/components/form/form.html @@ -4,7 +4,7 @@ {% csrf_token %} {% if title %} - {% include "components/form/title.html" with title=title only %} + {% include "components/header/header.html" with title=title only %} {% endif %} {% block intro %} diff --git a/src/newsreader/templates/components/form/title.html b/src/newsreader/templates/components/form/title.html deleted file mode 100644 index 3adcb75..0000000 --- a/src/newsreader/templates/components/form/title.html +++ /dev/null @@ -1,3 +0,0 @@ -
      -

      {{ title }}

      -
      diff --git a/src/newsreader/templates/components/header/header.html b/src/newsreader/templates/components/header/header.html new file mode 100644 index 0000000..c21c233 --- /dev/null +++ b/src/newsreader/templates/components/header/header.html @@ -0,0 +1,3 @@ +
      +

      {{ title }}

      +
      diff --git a/src/newsreader/utils/opml.py b/src/newsreader/utils/opml.py index 55a9387..1aca0fd 100644 --- a/src/newsreader/utils/opml.py +++ b/src/newsreader/utils/opml.py @@ -38,4 +38,5 @@ def parse_opml(file, user, skip_existing=False): logging.info(f"Skipped due to invalid URL: {e}") continue + # TODO create feed type rules yield CollectionRule(url=feed_url, name=name, user=user) From 576ab9a9175b4bfd22c46c8820767bd496ad037d Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Sun, 27 Sep 2020 16:13:59 +0200 Subject: [PATCH 176/422] Fix isort errors --- src/newsreader/news/collection/forms/reddit.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/newsreader/news/collection/forms/reddit.py b/src/newsreader/news/collection/forms/reddit.py index 0bcde9f..1744893 100644 --- a/src/newsreader/news/collection/forms/reddit.py +++ b/src/newsreader/news/collection/forms/reddit.py @@ -5,12 +5,10 @@ from django.utils.translation import gettext_lazy as _ import pytz -from newsreader.core.forms import CheckboxInput from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.forms.base import CollectionRuleForm from newsreader.news.collection.models import CollectionRule from newsreader.news.collection.reddit import REDDIT_API_URL -from newsreader.news.collection.forms.base import CollectionRuleForm -from newsreader.news.core.models import Category def get_reddit_help_text(): From d4a41a62da531891982129c9b844218bea3be3f7 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Sun, 27 Sep 2020 16:19:32 +0200 Subject: [PATCH 177/422] 0.3.0 - Add Twitter integration - Refactor alot of existing code in collection app - Update webpack font configuration --- docker-compose.yml | 8 +- poetry.lock | 609 +- pyproject.toml | 2 + src/newsreader/accounts/admin.py | 18 +- .../migrations/0011_auto_20200913_2101.py | 21 + .../migrations/0012_remove_user_task.py | 10 + src/newsreader/accounts/models.py | 42 +- .../accounts/components/settings-form.html | 19 +- .../accounts/views/integrations.html | 70 + .../templates/accounts/views/reddit.html | 13 +- .../templates/accounts/views/twitter.html | 20 + .../accounts/tests/test_integrations.py | 537 ++ .../accounts/tests/test_settings.py | 130 - src/newsreader/accounts/tests/tests.py | 26 +- src/newsreader/accounts/urls.py | 41 +- src/newsreader/accounts/views.py | 210 - src/newsreader/accounts/views/__init__.py | 26 + src/newsreader/accounts/views/auth.py | 11 + src/newsreader/accounts/views/integrations.py | 343 + src/newsreader/accounts/views/password.py | 37 + src/newsreader/accounts/views/registration.py | 59 + src/newsreader/accounts/views/settings.py | 26 + src/newsreader/conf/base.py | 39 +- src/newsreader/conf/production.py | 11 +- src/newsreader/fixtures/default-fixture.json | 8046 ++++++++--------- src/newsreader/fixtures/local/fixture.json | 12 +- src/newsreader/js/pages/categories/App.js | 3 +- .../categories/components/CategoryCard.js | 2 +- src/newsreader/js/pages/categories/index.js | 12 +- src/newsreader/js/pages/homepage/App.js | 10 +- .../js/pages/homepage/components/PostModal.js | 23 +- .../homepage/components/postlist/PostItem.js | 20 +- .../homepage/components/postlist/PostList.js | 11 +- src/newsreader/js/pages/homepage/constants.js | 1 + src/newsreader/js/pages/homepage/index.js | 12 +- src/newsreader/news/collection/admin.py | 9 +- src/newsreader/news/collection/base.py | 118 +- src/newsreader/news/collection/choices.py | 7 + src/newsreader/news/collection/constants.py | 5 +- src/newsreader/news/collection/favicon.py | 93 +- src/newsreader/news/collection/feed.py | 124 +- src/newsreader/news/collection/forms.py | 101 - .../news/collection/forms/__init__.py | 4 + src/newsreader/news/collection/forms/base.py | 29 + src/newsreader/news/collection/forms/feed.py | 28 + .../news/collection/forms/reddit.py | 49 + src/newsreader/news/collection/forms/rules.py | 14 + .../news/collection/forms/twitter.py | 35 + .../migrations/0009_auto_20200807_2030.py | 29 + .../migrations/0010_auto_20200913_2101.py | 24 + .../migrations/0011_auto_20200913_2157.py | 14 + src/newsreader/news/collection/models.py | 16 +- src/newsreader/news/collection/reddit.py | 260 +- src/newsreader/news/collection/tasks.py | 34 + .../{rule-create.html => feed-create.html} | 2 +- .../{rule-update.html => feed-update.html} | 6 +- .../news/collection/views/import.html | 2 +- .../news/collection/views/rules.html | 13 +- .../views/twitter/timeline-create.html | 9 + .../views/twitter/timeline-update.html | 14 + .../news/collection/tests/factories.py | 5 + .../collection/tests/favicon/builder/tests.py | 32 +- .../collection/tests/favicon/client/tests.py | 28 +- .../tests/favicon/collector/tests.py | 23 +- .../collection/tests/feed/builder/tests.py | 82 +- .../collection/tests/feed/client/tests.py | 4 +- .../collection/tests/feed/collector/tests.py | 82 +- .../collection/tests/feed/stream/tests.py | 6 +- .../collection/tests/reddit/builder/tests.py | 82 +- .../collection/tests/reddit/client/tests.py | 6 +- .../tests/reddit/collector/tests.py | 4 +- .../collection/tests/reddit/test_scheduler.py | 16 +- src/newsreader/news/collection/tests/tests.py | 96 +- .../news/collection/tests/twitter/__init__.py | 0 .../tests/twitter/builder/__init__.py | 0 .../collection/tests/twitter/builder/mocks.py | 2187 +++++ .../collection/tests/twitter/builder/tests.py | 412 + .../tests/twitter/client/__init__.py | 0 .../collection/tests/twitter/client/mocks.py | 225 + .../collection/tests/twitter/client/tests.py | 162 + .../tests/twitter/collector/__init__.py | 0 .../tests/twitter/collector/mocks.py | 227 + .../tests/twitter/collector/tests.py | 180 + .../tests/twitter/stream/__init__.py | 0 .../collection/tests/twitter/stream/mocks.py | 225 + .../collection/tests/twitter/stream/tests.py | 107 + .../tests/twitter/test_scheduler.py | 63 + .../news/collection/tests/utils/tests.py | 14 +- .../news/collection/tests/views/base.py | 2 +- .../news/collection/tests/views/test_crud.py | 18 +- .../tests/views/test_import_view.py | 4 +- .../tests/views/test_twitter_views.py | 129 + src/newsreader/news/collection/twitter.py | 281 + src/newsreader/news/collection/urls.py | 40 +- src/newsreader/news/collection/utils.py | 4 +- .../news/collection/views/__init__.py | 12 +- src/newsreader/news/collection/views/base.py | 28 +- src/newsreader/news/collection/views/feed.py | 70 + .../news/collection/views/reddit.py | 6 +- src/newsreader/news/collection/views/rules.py | 54 +- .../news/collection/views/twitter.py | 33 + .../templates/news/core/views/categories.html | 3 + .../templates/news/core/views/homepage.html | 11 +- src/newsreader/news/core/views.py | 34 +- .../scss/components/header/_header.scss | 3 + .../scss/components/header/index.scss | 1 + src/newsreader/scss/components/index.scss | 3 + .../integrations/_integrations.scss | 12 + .../scss/components/integrations/index.scss | 1 + .../scss/elements/button/_button.scss | 18 +- src/newsreader/scss/pages/index.scss | 1 + .../scss/pages/integrations/index.scss | 5 + src/newsreader/scss/partials/_colors.scss | 1 + .../templates/components/form/form.html | 2 +- .../templates/components/form/title.html | 3 - .../templates/components/header/header.html | 3 + src/newsreader/utils/opml.py | 1 + webpack.common.babel.js | 5 +- 118 files changed, 11060 insertions(+), 5515 deletions(-) create mode 100644 src/newsreader/accounts/migrations/0011_auto_20200913_2101.py create mode 100644 src/newsreader/accounts/migrations/0012_remove_user_task.py create mode 100644 src/newsreader/accounts/templates/accounts/views/integrations.html create mode 100644 src/newsreader/accounts/templates/accounts/views/twitter.html create mode 100644 src/newsreader/accounts/tests/test_integrations.py delete mode 100644 src/newsreader/accounts/views.py create mode 100644 src/newsreader/accounts/views/__init__.py create mode 100644 src/newsreader/accounts/views/auth.py create mode 100644 src/newsreader/accounts/views/integrations.py create mode 100644 src/newsreader/accounts/views/password.py create mode 100644 src/newsreader/accounts/views/registration.py create mode 100644 src/newsreader/accounts/views/settings.py delete mode 100644 src/newsreader/news/collection/forms.py create mode 100644 src/newsreader/news/collection/forms/__init__.py create mode 100644 src/newsreader/news/collection/forms/base.py create mode 100644 src/newsreader/news/collection/forms/feed.py create mode 100644 src/newsreader/news/collection/forms/reddit.py create mode 100644 src/newsreader/news/collection/forms/rules.py create mode 100644 src/newsreader/news/collection/forms/twitter.py create mode 100644 src/newsreader/news/collection/migrations/0009_auto_20200807_2030.py create mode 100644 src/newsreader/news/collection/migrations/0010_auto_20200913_2101.py create mode 100644 src/newsreader/news/collection/migrations/0011_auto_20200913_2157.py rename src/newsreader/news/collection/templates/news/collection/views/{rule-create.html => feed-create.html} (78%) rename src/newsreader/news/collection/templates/news/collection/views/{rule-update.html => feed-update.html} (72%) create mode 100644 src/newsreader/news/collection/templates/news/collection/views/twitter/timeline-create.html create mode 100644 src/newsreader/news/collection/templates/news/collection/views/twitter/timeline-update.html create mode 100644 src/newsreader/news/collection/tests/twitter/__init__.py create mode 100644 src/newsreader/news/collection/tests/twitter/builder/__init__.py create mode 100644 src/newsreader/news/collection/tests/twitter/builder/mocks.py create mode 100644 src/newsreader/news/collection/tests/twitter/builder/tests.py create mode 100644 src/newsreader/news/collection/tests/twitter/client/__init__.py create mode 100644 src/newsreader/news/collection/tests/twitter/client/mocks.py create mode 100644 src/newsreader/news/collection/tests/twitter/client/tests.py create mode 100644 src/newsreader/news/collection/tests/twitter/collector/__init__.py create mode 100644 src/newsreader/news/collection/tests/twitter/collector/mocks.py create mode 100644 src/newsreader/news/collection/tests/twitter/collector/tests.py create mode 100644 src/newsreader/news/collection/tests/twitter/stream/__init__.py create mode 100644 src/newsreader/news/collection/tests/twitter/stream/mocks.py create mode 100644 src/newsreader/news/collection/tests/twitter/stream/tests.py create mode 100644 src/newsreader/news/collection/tests/twitter/test_scheduler.py create mode 100644 src/newsreader/news/collection/tests/views/test_twitter_views.py create mode 100644 src/newsreader/news/collection/twitter.py create mode 100644 src/newsreader/news/collection/views/feed.py create mode 100644 src/newsreader/news/collection/views/twitter.py create mode 100644 src/newsreader/scss/components/header/_header.scss create mode 100644 src/newsreader/scss/components/header/index.scss create mode 100644 src/newsreader/scss/components/integrations/_integrations.scss create mode 100644 src/newsreader/scss/components/integrations/index.scss create mode 100644 src/newsreader/scss/pages/integrations/index.scss delete mode 100644 src/newsreader/templates/components/form/title.html create mode 100644 src/newsreader/templates/components/header/header.html diff --git a/docker-compose.yml b/docker-compose.yml index c7dc5ca..8ce24e3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3' +version: "3" volumes: postgres-data: static-files: @@ -16,7 +16,7 @@ services: rabbitmq: image: rabbitmq:3.7 memcached: - image: memcached:1.5.22 + image: memcached:1.6 ports: - "11211:11211" entrypoint: @@ -31,6 +31,7 @@ services: - DJANGO_SETTINGS_MODULE=newsreader.conf.docker depends_on: - rabbitmq + - memcached volumes: - .:/app django: @@ -41,9 +42,10 @@ services: environment: - DJANGO_SETTINGS_MODULE=newsreader.conf.docker ports: - - '8000:8000' + - "8000:8000" depends_on: - db + - memcached volumes: - .:/app - static-files:/app/src/newsreader/static diff --git a/poetry.lock b/poetry.lock index cab45d1..0bbd4e5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,40 +1,40 @@ [[package]] -category = "main" -description = "Low-level AMQP client for Python (fork of amqplib)." name = "amqp" +version = "2.5.2" +description = "Low-level AMQP client for Python (fork of amqplib)." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.5.2" [package.dependencies] vine = ">=1.1.3,<5.0.0a1" [[package]] -category = "dev" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." name = "appdirs" +version = "1.4.3" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" optional = false python-versions = "*" -version = "1.4.3" [[package]] -category = "main" -description = "ASGI specs, helper code, and adapters" name = "asgiref" +version = "3.2.7" +description = "ASGI specs, helper code, and adapters" +category = "main" optional = false python-versions = ">=3.5" -version = "3.2.7" [package.extras] tests = ["pytest (>=4.3.0,<4.4.0)", "pytest-asyncio (>=0.10.0,<0.11.0)"] [[package]] -category = "dev" -description = "Classes Without Boilerplate" name = "attrs" +version = "19.3.0" +description = "Classes Without Boilerplate" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "19.3.0" [package.extras] azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"] @@ -43,46 +43,49 @@ docs = ["sphinx", "zope.interface"] tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] [[package]] -category = "dev" -description = "Removes unused imports and unused variables" name = "autoflake" +version = "1.3.1" +description = "Removes unused imports and unused variables" +category = "dev" optional = false python-versions = "*" -version = "1.3.1" [package.dependencies] pyflakes = ">=1.1.0" [[package]] -category = "main" -description = "Screen-scraping library" name = "beautifulsoup4" +version = "4.9.0" +description = "Screen-scraping library" +category = "main" optional = false python-versions = "*" -version = "4.9.0" - -[package.dependencies] -soupsieve = [">1.2", "<2.0"] [package.extras] html5lib = ["html5lib"] lxml = ["lxml"] -[[package]] -category = "main" -description = "Python multiprocessing fork with improvements and bugfixes" -name = "billiard" -optional = false -python-versions = "*" -version = "3.6.3.0" +[package.dependencies] +soupsieve = [">1.2", "<2.0"] + +[[package]] +name = "billiard" +version = "3.6.3.0" +description = "Python multiprocessing fork with improvements and bugfixes" +category = "main" +optional = false +python-versions = "*" [[package]] -category = "dev" -description = "The uncompromising code formatter." name = "black" +version = "19.3b0" +description = "The uncompromising code formatter." +category = "dev" optional = false python-versions = ">=3.6" -version = "19.3b0" + +[package.extras] +d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] [package.dependencies] appdirs = "*" @@ -90,34 +93,25 @@ attrs = ">=18.1.0" click = ">=6.5" toml = ">=0.9.4" -[package.extras] -d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] - [[package]] -category = "main" -description = "An easy safelist-based HTML-sanitizing tool." name = "bleach" +version = "3.1.4" +description = "An easy safelist-based HTML-sanitizing tool." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "3.1.4" [package.dependencies] six = ">=1.9.0" webencodings = "*" [[package]] -category = "main" -description = "Distributed Task Queue." name = "celery" +version = "4.4.2" +description = "Distributed Task Queue." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*," -version = "4.4.2" - -[package.dependencies] -billiard = ">=3.6.3.0,<4.0" -kombu = ">=4.6.8,<4.7" -pytz = ">0.0-dev" -vine = "1.3.0" [package.extras] arangodb = ["pyArango (>=1.3.2)"] @@ -153,37 +147,43 @@ yaml = ["PyYAML (>=3.10)"] zookeeper = ["kazoo (>=1.3.1)"] zstd = ["zstandard"] +[package.dependencies] +billiard = ">=3.6.3.0,<4.0" +kombu = ">=4.6.8,<4.7" +pytz = ">0.0-dev" +vine = "1.3.0" + [[package]] -category = "main" -description = "Python package for providing Mozilla's CA Bundle." name = "certifi" -optional = false -python-versions = "*" version = "2020.4.5.1" - -[[package]] +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "chardet" +version = "3.0.4" +description = "Universal encoding detector for Python 2 and 3" category = "main" -description = "Universal encoding detector for Python 2 and 3" -name = "chardet" optional = false python-versions = "*" -version = "3.0.4" [[package]] -category = "dev" -description = "Composable command line interface toolkit" name = "click" +version = "7.1.1" +description = "Composable command line interface toolkit" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "7.1.1" [[package]] -category = "main" -description = "Python client library for Core API." name = "coreapi" +version = "2.3.3" +description = "Python client library for Core API." +category = "main" optional = false python-versions = "*" -version = "2.3.3" [package.dependencies] coreschema = "*" @@ -192,62 +192,62 @@ requests = "*" uritemplate = "*" [[package]] -category = "main" -description = "Core Schema." name = "coreschema" +version = "0.0.4" +description = "Core Schema." +category = "main" optional = false python-versions = "*" -version = "0.0.4" [package.dependencies] jinja2 = "*" [[package]] -category = "dev" -description = "Code coverage measurement for Python" name = "coverage" +version = "5.1" +description = "Code coverage measurement for Python" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" -version = "5.1" [package.extras] toml = ["toml"] [[package]] -category = "main" -description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." name = "django" +version = "3.0.7" +description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." +category = "main" optional = false python-versions = ">=3.6" -version = "3.0.7" + +[package.extras] +argon2 = ["argon2-cffi (>=16.1.0)"] +bcrypt = ["bcrypt"] [package.dependencies] asgiref = ">=3.2,<4.0" pytz = "*" sqlparse = ">=0.2.2" -[package.extras] -argon2 = ["argon2-cffi (>=16.1.0)"] -bcrypt = ["bcrypt"] - [[package]] -category = "main" -description = "A helper class for handling configuration defaults of packaged apps gracefully." name = "django-appconf" +version = "1.0.4" +description = "A helper class for handling configuration defaults of packaged apps gracefully." +category = "main" optional = false python-versions = "*" -version = "1.0.4" [package.dependencies] django = "*" [[package]] -category = "main" -description = "Keep track of failed login attempts in Django-powered sites." name = "django-axes" +version = "5.3.1" +description = "Keep track of failed login attempts in Django-powered sites." +category = "main" optional = false python-versions = "~=3.6" -version = "5.3.1" [package.dependencies] django = ">=1.11" @@ -255,93 +255,96 @@ django-appconf = ">=1.0.3" django-ipware = ">=2.0.2" [[package]] -category = "main" -description = "Database-backed Periodic Tasks." name = "django-celery-beat" +version = "2.0.0" +description = "Database-backed Periodic Tasks." +category = "main" optional = false python-versions = "*" -version = "2.0.0" [package.dependencies] -Django = ">=1.11.17" celery = "*" +Django = ">=1.11.17" django-timezone-field = ">=4.0,<5.0" python-crontab = ">=2.3.4" [[package]] -category = "dev" -description = "A configurable set of panels that display various debug information about the current request/response." name = "django-debug-toolbar" +version = "2.2" +description = "A configurable set of panels that display various debug information about the current request/response." +category = "dev" optional = false python-versions = ">=3.5" -version = "2.2" [package.dependencies] Django = ">=1.11" sqlparse = ">=0.2.0" [[package]] -category = "dev" -description = "Extensions for Django" name = "django-extensions" +version = "2.2.9" +description = "Extensions for Django" +category = "dev" optional = false python-versions = "*" -version = "2.2.9" [package.dependencies] six = ">=1.2" [[package]] -category = "main" -description = "A Django utility application that returns client's real IP address" name = "django-ipware" -optional = false -python-versions = "*" version = "2.1.0" - -[[package]] +description = "A Django utility application that returns client's real IP address" category = "main" -description = "An extensible user-registration application for Django" -name = "django-registration-redux" optional = false python-versions = "*" -version = "2.7" [[package]] +name = "django-registration-redux" +version = "2.7" +description = "An extensible user-registration application for Django" category = "main" -description = "A Django app providing database and form fields for pytz timezone objects." +optional = false +python-versions = "*" + +[[package]] name = "django-timezone-field" +version = "4.0" +description = "A Django app providing database and form fields for pytz timezone objects." +category = "main" optional = false python-versions = ">=3.5" -version = "4.0" [package.dependencies] django = ">=2.2" pytz = "*" [[package]] -category = "main" -description = "Web APIs for Django, made easy." name = "djangorestframework" +version = "3.11.0" +description = "Web APIs for Django, made easy." +category = "main" optional = false python-versions = ">=3.5" -version = "3.11.0" [package.dependencies] django = ">=1.11" [[package]] -category = "main" -description = "Automated generation of real Swagger/OpenAPI 2.0 schemas from Django Rest Framework code." name = "drf-yasg" +version = "1.17.1" +description = "Automated generation of real Swagger/OpenAPI 2.0 schemas from Django Rest Framework code." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "1.17.1" + +[package.extras] +validation = ["swagger-spec-validator (>=2.1.0)"] [package.dependencies] -Django = ">=1.11.7" coreapi = ">=2.3.3" coreschema = ">=0.0.4" +Django = ">=1.11.7" djangorestframework = ">=3.8" inflection = ">=0.3.1" packaging = "*" @@ -349,62 +352,67 @@ packaging = "*" six = ">=1.10.0" uritemplate = ">=3.0.0" -[package.extras] -validation = ["swagger-spec-validator (>=2.1.0)"] - [[package]] -category = "dev" -description = "A versatile test fixtures replacement based on thoughtbot's factory_bot for Ruby." name = "factory-boy" +version = "2.12.0" +description = "A versatile test fixtures replacement based on thoughtbot's factory_bot for Ruby." +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.12.0" [package.dependencies] Faker = ">=0.7.0" [[package]] -category = "dev" -description = "Faker is a Python package that generates fake data for you." name = "faker" +version = "4.0.2" +description = "Faker is a Python package that generates fake data for you." +category = "dev" optional = false python-versions = ">=3.4" -version = "4.0.2" [package.dependencies] python-dateutil = ">=2.4" text-unidecode = "1.3" [[package]] -category = "main" -description = "Universal feed parser, handles RSS 0.9x, RSS 1.0, RSS 2.0, CDF, Atom 0.3, and Atom 1.0 feeds" name = "feedparser" +version = "5.2.1" +description = "Universal feed parser, handles RSS 0.9x, RSS 1.0, RSS 2.0, CDF, Atom 0.3, and Atom 1.0 feeds" +category = "main" optional = false python-versions = "*" -version = "5.2.1" [[package]] -category = "dev" -description = "Let your Python tests travel through time" name = "freezegun" +version = "0.3.15" +description = "Let your Python tests travel through time" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "0.3.15" [package.dependencies] python-dateutil = ">=1.0,<2.0 || >2.0" six = "*" [[package]] +name = "ftfy" +version = "5.8" +description = "Fixes some problems with Unicode text after the fact" category = "main" -description = "WSGI HTTP Server for UNIX" -name = "gunicorn" optional = false -python-versions = ">=3.4" -version = "20.0.4" +python-versions = ">=3.5" [package.dependencies] -setuptools = ">=3.0" +wcwidth = "*" + +[[package]] +name = "gunicorn" +version = "20.0.4" +description = "WSGI HTTP Server for UNIX" +category = "main" +optional = false +python-versions = ">=3.4" [package.extras] eventlet = ["eventlet (>=0.9.7)"] @@ -412,45 +420,48 @@ gevent = ["gevent (>=0.13)"] setproctitle = ["setproctitle"] tornado = ["tornado (>=0.2)"] +[package.dependencies] +setuptools = ">=3.0" + [[package]] -category = "main" -description = "Internationalized Domain Names in Applications (IDNA)" name = "idna" +version = "2.9" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.9" [[package]] -category = "main" -description = "Read metadata from Python packages" -marker = "python_version < \"3.8\"" name = "importlib-metadata" +version = "1.6.0" +description = "Read metadata from Python packages" +category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" -version = "1.6.0" - -[package.dependencies] -zipp = ">=0.5" +marker = "python_version < \"3.8\"" [package.extras] docs = ["sphinx", "rst.linker"] testing = ["packaging", "importlib-resources"] -[[package]] -category = "main" -description = "A port of Ruby on Rails inflector to Python" -name = "inflection" -optional = false -python-versions = ">=3.5" -version = "0.4.0" +[package.dependencies] +zipp = ">=0.5" + +[[package]] +name = "inflection" +version = "0.4.0" +description = "A port of Ruby on Rails inflector to Python" +category = "main" +optional = false +python-versions = ">=3.5" [[package]] -category = "dev" -description = "A Python utility / library to sort Python imports." name = "isort" +version = "4.3.21" +description = "A Python utility / library to sort Python imports." +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "4.3.21" [package.extras] pipfile = ["pipreqs", "requirementslib"] @@ -459,41 +470,34 @@ requirements = ["pipreqs", "pip-api"] xdg_home = ["appdirs (>=1.4.0)"] [[package]] -category = "main" -description = "Simple immutable types for python." name = "itypes" +version = "1.1.0" +description = "Simple immutable types for python." +category = "main" optional = false python-versions = "*" -version = "1.1.0" [[package]] -category = "main" -description = "A very fast and expressive template engine." name = "jinja2" +version = "2.11.1" +description = "A very fast and expressive template engine." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.11.1" - -[package.dependencies] -MarkupSafe = ">=0.23" [package.extras] i18n = ["Babel (>=0.8)"] +[package.dependencies] +MarkupSafe = ">=0.23" + [[package]] -category = "main" -description = "Messaging library for Python." name = "kombu" +version = "4.6.8" +description = "Messaging library for Python." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "4.6.8" - -[package.dependencies] -amqp = ">=2.5.2,<2.6" - -[package.dependencies.importlib-metadata] -python = "<3.8" -version = ">=0.18" [package.extras] azureservicebus = ["azure-servicebus (>=0.21.1)"] @@ -511,13 +515,20 @@ sqs = ["boto3 (>=1.4.4)", "pycurl (7.43.0.2)"] yaml = ["PyYAML (>=3.10)"] zookeeper = ["kazoo (>=1.3.1)"] +[package.dependencies] +amqp = ">=2.5.2,<2.6" + +[package.dependencies.importlib-metadata] +version = ">=0.18" +python = "<3.8" + [[package]] -category = "main" -description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." name = "lxml" +version = "4.5.0" +description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*" -version = "4.5.0" [package.extras] cssselect = ["cssselect (>=0.7)"] @@ -526,112 +537,129 @@ htmlsoup = ["beautifulsoup4"] source = ["Cython (>=0.29.7)"] [[package]] -category = "main" -description = "Safely add untrusted strings to HTML/XML markup." name = "markupsafe" +version = "1.1.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "main" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" -version = "1.1.1" [[package]] +name = "oauthlib" +version = "3.1.0" +description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" category = "main" -description = "Core utilities for Python packages" -name = "packaging" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +rsa = ["cryptography"] +signals = ["blinker"] +signedtoken = ["cryptography", "pyjwt (>=1.0.0)"] + +[[package]] +name = "packaging" version = "20.3" +description = "Core utilities for Python packages" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.dependencies] pyparsing = ">=2.0.2" six = "*" [[package]] -category = "main" -description = "psycopg2 - Python-PostgreSQL Database Adapter" name = "psycopg2-binary" +version = "2.8.5" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +category = "main" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" -version = "2.8.5" [[package]] -category = "dev" -description = "passive checker of Python programs" name = "pyflakes" +version = "2.2.0" +description = "passive checker of Python programs" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.2.0" [[package]] -category = "main" -description = "Python parsing module" name = "pyparsing" +version = "2.4.7" +description = "Python parsing module" +category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -version = "2.4.7" [[package]] -category = "main" -description = "Python Crontab API" name = "python-crontab" +version = "2.4.1" +description = "Python Crontab API" +category = "main" optional = false python-versions = "*" -version = "2.4.1" - -[package.dependencies] -python-dateutil = "*" [package.extras] cron-description = ["cron-descriptor"] cron-schedule = ["croniter"] +[package.dependencies] +python-dateutil = "*" + [[package]] -category = "main" -description = "Extensions to the standard Python datetime module" name = "python-dateutil" +version = "2.8.1" +description = "Extensions to the standard Python datetime module" +category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -version = "2.8.1" [package.dependencies] six = ">=1.5" [[package]] -category = "main" -description = "Add .env support to your django/flask apps in development and deployments" name = "python-dotenv" +version = "0.12.0" +description = "Add .env support to your django/flask apps in development and deployments" +category = "main" optional = false python-versions = "*" -version = "0.12.0" [package.extras] cli = ["click (>=5.0)"] [[package]] -category = "main" -description = "Pure python memcached client" name = "python-memcached" +version = "1.59" +description = "Pure python memcached client" +category = "main" optional = false python-versions = "*" -version = "1.59" [package.dependencies] six = ">=1.4.0" [[package]] -category = "main" -description = "World timezone definitions, modern and historical" name = "pytz" +version = "2019.3" +description = "World timezone definitions, modern and historical" +category = "main" optional = false python-versions = "*" -version = "2019.3" [[package]] -category = "main" -description = "Python HTTP for Humans." name = "requests" +version = "2.23.0" +description = "Python HTTP for Humans." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.23.0" + +[package.extras] +security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] +socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] [package.dependencies] certifi = ">=2017.4.17" @@ -639,47 +667,54 @@ chardet = ">=3.0.2,<4" idna = ">=2.5,<3" urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" -[package.extras] -security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] -socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] - [[package]] +name = "requests-oauthlib" +version = "1.3.0" +description = "OAuthlib authentication support for Requests." category = "main" -description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" -name = "ruamel.yaml" optional = false -python-versions = "*" -version = "0.16.10" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +rsa = ["oauthlib (>=3.0.0)"] [package.dependencies] -[package.dependencies."ruamel.yaml.clib"] -python = "<3.9" -version = ">=0.1.2" +oauthlib = ">=3.0.0" +requests = ">=2.0.0" + +[[package]] +name = "ruamel.yaml" +version = "0.16.10" +description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" +category = "main" +optional = false +python-versions = "*" [package.extras] docs = ["ryd"] jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] -[[package]] -category = "main" -description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" -marker = "platform_python_implementation == \"CPython\" and python_version < \"3.9\"" -name = "ruamel.yaml.clib" -optional = false -python-versions = "*" -version = "0.2.0" - -[[package]] -category = "main" -description = "Python client for Sentry (https://getsentry.com)" -name = "sentry-sdk" -optional = false -python-versions = "*" -version = "0.15.1" - [package.dependencies] -certifi = "*" -urllib3 = ">=1.10.0" +[package.dependencies."ruamel.yaml.clib"] +version = ">=0.1.2" +python = "<3.9" + +[[package]] +name = "ruamel.yaml.clib" +version = "0.2.0" +description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" +category = "main" +optional = false +python-versions = "*" +marker = "platform_python_implementation == \"CPython\" and python_version < \"3.9\"" + +[[package]] +name = "sentry-sdk" +version = "0.15.1" +description = "Python client for Sentry (https://getsentry.com)" +category = "main" +optional = false +python-versions = "*" [package.extras] aiohttp = ["aiohttp (>=3.5)"] @@ -695,69 +730,73 @@ sanic = ["sanic (>=0.8)"] sqlalchemy = ["sqlalchemy (>=1.2)"] tornado = ["tornado (>=5)"] +[package.dependencies] +certifi = "*" +urllib3 = ">=1.10.0" + [[package]] -category = "main" -description = "Python 2 and 3 compatibility utilities" name = "six" +version = "1.14.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -version = "1.14.0" [[package]] -category = "main" -description = "A modern CSS selector implementation for Beautiful Soup." name = "soupsieve" +version = "1.9.5" +description = "A modern CSS selector implementation for Beautiful Soup." +category = "main" optional = false python-versions = "*" -version = "1.9.5" [[package]] -category = "main" -description = "Non-validating SQL parser" name = "sqlparse" +version = "0.3.1" +description = "Non-validating SQL parser" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "0.3.1" [[package]] -category = "dev" -description = "Traceback serialization library." name = "tblib" +version = "1.6.0" +description = "Traceback serialization library." +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "1.6.0" [[package]] -category = "dev" -description = "The most basic Text::Unidecode port" name = "text-unidecode" -optional = false -python-versions = "*" version = "1.3" - -[[package]] +description = "The most basic Text::Unidecode port" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "toml" +version = "0.10.0" +description = "Python Library for Tom's Obvious, Minimal Language" category = "dev" -description = "Python Library for Tom's Obvious, Minimal Language" -name = "toml" optional = false python-versions = "*" -version = "0.10.0" [[package]] -category = "main" -description = "URI templates" name = "uritemplate" +version = "3.0.1" +description = "URI templates" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "3.0.1" [[package]] -category = "main" -description = "HTTP library with thread-safe connection pooling, file post, and more." name = "urllib3" +version = "1.25.8" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" -version = "1.25.8" [package.extras] brotli = ["brotlipy (>=0.6.0)"] @@ -765,37 +804,46 @@ secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "cer socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] [[package]] -category = "main" -description = "Promises, promises, promises." name = "vine" +version = "1.3.0" +description = "Promises, promises, promises." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.3.0" [[package]] +name = "wcwidth" +version = "0.2.5" +description = "Measures the displayed width of unicode strings in a terminal" category = "main" -description = "Character encoding aliases for legacy web content" -name = "webencodings" optional = false python-versions = "*" -version = "0.5.1" [[package]] +name = "webencodings" +version = "0.5.1" +description = "Character encoding aliases for legacy web content" category = "main" -description = "Backport of pathlib-compatible object wrapper for zip files" -marker = "python_version < \"3.8\"" +optional = false +python-versions = "*" + +[[package]] name = "zipp" +version = "3.1.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "main" optional = false python-versions = ">=3.6" -version = "3.1.0" +marker = "python_version < \"3.8\"" [package.extras] docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] [metadata] -content-hash = "6b207d452b10de2399c4c49118da997dda6ed1bb0437963c3f415ecd3d806fe5" +lock-version = "1.0" python-versions = "^3.7" +content-hash = "cda651cbf92ffc53c6ef09bea6204f5927b5a1bf3feff85bc70fa672e526cc91" [metadata.files] amqp = [ @@ -951,6 +999,9 @@ freezegun = [ {file = "freezegun-0.3.15-py2.py3-none-any.whl", hash = "sha256:82c757a05b7c7ca3e176bfebd7d6779fd9139c7cb4ef969c38a28d74deef89b2"}, {file = "freezegun-0.3.15.tar.gz", hash = "sha256:e2062f2c7f95cc276a834c22f1a17179467176b624cc6f936e8bc3be5535ad1b"}, ] +ftfy = [ + {file = "ftfy-5.8.tar.gz", hash = "sha256:51c7767f8c4b47d291fcef30b9625fb5341c06a31e6a3b627039c706c42f3720"}, +] gunicorn = [ {file = "gunicorn-20.0.4-py2.py3-none-any.whl", hash = "sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c"}, {file = "gunicorn-20.0.4.tar.gz", hash = "sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626"}, @@ -1046,6 +1097,10 @@ markupsafe = [ {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, ] +oauthlib = [ + {file = "oauthlib-3.1.0-py2.py3-none-any.whl", hash = "sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea"}, + {file = "oauthlib-3.1.0.tar.gz", hash = "sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889"}, +] packaging = [ {file = "packaging-20.3-py2.py3-none-any.whl", hash = "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752"}, {file = "packaging-20.3.tar.gz", hash = "sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3"}, @@ -1110,9 +1165,15 @@ pytz = [ {file = "pytz-2019.3.tar.gz", hash = "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"}, ] requests = [ + {file = "requests-2.23.0-py2.7.egg", hash = "sha256:5d2d0ffbb515f39417009a46c14256291061ac01ba8f875b90cad137de83beb4"}, {file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"}, {file = "requests-2.23.0.tar.gz", hash = "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"}, ] +requests-oauthlib = [ + {file = "requests-oauthlib-1.3.0.tar.gz", hash = "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a"}, + {file = "requests_oauthlib-1.3.0-py2.py3-none-any.whl", hash = "sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d"}, + {file = "requests_oauthlib-1.3.0-py3.7.egg", hash = "sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc"}, +] "ruamel.yaml" = [ {file = "ruamel.yaml-0.16.10-py2.py3-none-any.whl", hash = "sha256:0962fd7999e064c4865f96fb1e23079075f4a2a14849bcdc5cdba53a24f9759b"}, {file = "ruamel.yaml-0.16.10.tar.gz", hash = "sha256:099c644a778bf72ffa00524f78dd0b6476bca94a1da344130f4bf3381ce5b954"}, @@ -1179,6 +1240,10 @@ vine = [ {file = "vine-1.3.0-py2.py3-none-any.whl", hash = "sha256:ea4947cc56d1fd6f2095c8d543ee25dad966f78692528e68b4fada11ba3f98af"}, {file = "vine-1.3.0.tar.gz", hash = "sha256:133ee6d7a9016f177ddeaf191c1f58421a1dcc6ee9a42c58b34bed40e1d2cd87"}, ] +wcwidth = [ + {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, + {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, +] webencodings = [ {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, diff --git a/pyproject.toml b/pyproject.toml index bdc34a9..2d400ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,8 @@ gunicorn = "^20.0.4" python-dotenv = "^0.12.0" django = ">=3.0.7" sentry-sdk = "^0.15.1" +ftfy = "^5.8" +requests_oauthlib = "^1.3.0" [tool.poetry.dev-dependencies] factory-boy = "^2.12.0" diff --git a/src/newsreader/accounts/admin.py b/src/newsreader/accounts/admin.py index 49390c7..02d372c 100644 --- a/src/newsreader/accounts/admin.py +++ b/src/newsreader/accounts/admin.py @@ -11,8 +11,18 @@ class UserAdminForm(UserChangeForm): class Meta: widgets = { "email": forms.EmailInput(attrs={"size": "50"}), - "reddit_access_token": forms.TextInput(attrs={"size": "90"}), - "reddit_refresh_token": forms.TextInput(attrs={"size": "90"}), + "reddit_access_token": forms.PasswordInput( + attrs={"size": "90"}, render_value=True + ), + "reddit_refresh_token": forms.PasswordInput( + attrs={"size": "90"}, render_value=True + ), + "twitter_oauth_token": forms.PasswordInput( + attrs={"size": "90"}, render_value=True + ), + "twitter_oauth_token_secret": forms.PasswordInput( + attrs={"size": "90"}, render_value=True + ), } @@ -34,6 +44,10 @@ class UserAdmin(DjangoUserAdmin): _("Reddit settings"), {"fields": ("reddit_access_token", "reddit_refresh_token")}, ), + ( + _("Twitter settings"), + {"fields": ("twitter_oauth_token", "twitter_oauth_token_secret")}, + ), ( _("Permission settings"), {"classes": ("collapse",), "fields": ("is_staff", "is_superuser")}, diff --git a/src/newsreader/accounts/migrations/0011_auto_20200913_2101.py b/src/newsreader/accounts/migrations/0011_auto_20200913_2101.py new file mode 100644 index 0000000..b6a83dd --- /dev/null +++ b/src/newsreader/accounts/migrations/0011_auto_20200913_2101.py @@ -0,0 +1,21 @@ +# Generated by Django 3.0.7 on 2020-09-13 19:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("accounts", "0010_auto_20200603_2230")] + + operations = [ + migrations.AddField( + model_name="user", + name="twitter_oauth_token", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="user", + name="twitter_oauth_token_secret", + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/src/newsreader/accounts/migrations/0012_remove_user_task.py b/src/newsreader/accounts/migrations/0012_remove_user_task.py new file mode 100644 index 0000000..250d300 --- /dev/null +++ b/src/newsreader/accounts/migrations/0012_remove_user_task.py @@ -0,0 +1,10 @@ +# Generated by Django 3.0.7 on 2020-09-26 15:34 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [("accounts", "0011_auto_20200913_2101")] + + operations = [migrations.RemoveField(model_name="user", name="task")] diff --git a/src/newsreader/accounts/models.py b/src/newsreader/accounts/models.py index b8aaa64..2451445 100644 --- a/src/newsreader/accounts/models.py +++ b/src/newsreader/accounts/models.py @@ -1,11 +1,9 @@ -import json - from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import UserManager as DjangoUserManager from django.db import models from django.utils.translation import gettext as _ -from django_celery_beat.models import IntervalSchedule, PeriodicTask +from django_celery_beat.models import PeriodicTask class UserManager(DjangoUserManager): @@ -41,18 +39,12 @@ class UserManager(DjangoUserManager): class User(AbstractUser): email = models.EmailField(_("email address"), unique=True) - task = models.OneToOneField( - PeriodicTask, - on_delete=models.CASCADE, - null=True, - blank=True, - editable=False, - verbose_name="collection task", - ) - reddit_refresh_token = models.CharField(max_length=255, blank=True, null=True) reddit_access_token = models.CharField(max_length=255, blank=True, null=True) + twitter_oauth_token = models.CharField(max_length=255, blank=True, null=True) + twitter_oauth_token_secret = models.CharField(max_length=255, blank=True, null=True) + username = None objects = UserManager() @@ -60,24 +52,12 @@ class User(AbstractUser): USERNAME_FIELD = "email" REQUIRED_FIELDS = [] - def save(self, *args, **kwargs): - super().save(*args, **kwargs) - - if not self.task: - task_interval, _ = IntervalSchedule.objects.get_or_create( - every=1, period=IntervalSchedule.HOURS - ) - - self.task, _ = PeriodicTask.objects.get_or_create( - enabled=True, - interval=task_interval, - name=f"{self.email}-collection-task", - task="FeedTask", - args=json.dumps([self.pk]), - ) - - self.save() - def delete(self, *args, **kwargs): - self.task.delete() + tasks = PeriodicTask.objects.filter(name__contains=self.email) + tasks.delete() + return super().delete(*args, **kwargs) + + @property + def has_twitter_auth(self): + return self.twitter_oauth_token and self.twitter_oauth_token_secret diff --git a/src/newsreader/accounts/templates/accounts/components/settings-form.html b/src/newsreader/accounts/templates/accounts/components/settings-form.html index 7942354..51d4450 100644 --- a/src/newsreader/accounts/templates/accounts/components/settings-form.html +++ b/src/newsreader/accounts/templates/accounts/components/settings-form.html @@ -3,28 +3,15 @@ {% block actions %}
      -
      - {% include "components/form/cancel-button.html" %} -
      -
      {% trans "Change password" %} + + {% trans "Third party integrations" %} + {% include "components/form/confirm-button.html" %} - - {% if reddit_authorization_url %} - - {% trans "Authorize Reddit account" %} - - {% endif %} - - {% if reddit_refresh_url %} - - {% trans "Refresh Reddit access token" %} - - {% endif %}
      {% endblock actions %} diff --git a/src/newsreader/accounts/templates/accounts/views/integrations.html b/src/newsreader/accounts/templates/accounts/views/integrations.html new file mode 100644 index 0000000..4429f02 --- /dev/null +++ b/src/newsreader/accounts/templates/accounts/views/integrations.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block content %} +
      +
      + {% include "components/header/header.html" with title="Integrations" only %} + +
      +

      Reddit

      +
      + {% if reddit_authorization_url %} + + {% trans "Authorize account" %} + + {% else %} + + {% endif %} + + {% if reddit_refresh_url %} + + {% trans "Refresh token" %} + + {% else %} + + {% endif %} + + {% if reddit_revoke_url %} + + {% trans "Deauthorize account" %} + + {% else %} + + {% endif %} +
      +
      + +
      +

      Twitter

      +
      + {% if twitter_auth_url %} + + {% else %} + + {% endif %} + + {% if twitter_revoke_url %} + + {% else %} + + {% endif %} +
      +
      +
      +
      +{% endblock %} diff --git a/src/newsreader/accounts/templates/accounts/views/reddit.html b/src/newsreader/accounts/templates/accounts/views/reddit.html index b393bbe..5d4f539 100644 --- a/src/newsreader/accounts/templates/accounts/views/reddit.html +++ b/src/newsreader/accounts/templates/accounts/views/reddit.html @@ -1,17 +1,20 @@ {% extends "base.html" %} +{% load i18n %} {% block content %} -
      +
      {% if error %} -

      Reddit authorization failed

      +

      {% trans "Reddit authorization failed" %}

      {{ error }}

      {% elif access_token and refresh_token %} -

      Reddit account is linked

      -

      Your reddit account was successfully linked.

      +

      {% trans "Reddit account is linked" %}

      +

      {% trans "Your reddit account was successfully linked." %}

      {% endif %} -

      Return to settings page

      +

      + {% trans "Return to integrations page" %} +

      {% endblock %} diff --git a/src/newsreader/accounts/templates/accounts/views/twitter.html b/src/newsreader/accounts/templates/accounts/views/twitter.html new file mode 100644 index 0000000..e2c51aa --- /dev/null +++ b/src/newsreader/accounts/templates/accounts/views/twitter.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block content %} +
      +
      + {% if error %} +

      {% trans "Twitter authorization failed" %}

      +

      {{ error }}

      + {% elif authorized %} +

      {% trans "Twitter account is linked" %}

      +

      {% trans "Your Twitter account was successfully linked." %}

      + {% endif %} + +

      + {% trans "Return to integrations page" %} +

      +
      +
      +{% endblock %} diff --git a/src/newsreader/accounts/tests/test_integrations.py b/src/newsreader/accounts/tests/test_integrations.py new file mode 100644 index 0000000..cdc9546 --- /dev/null +++ b/src/newsreader/accounts/tests/test_integrations.py @@ -0,0 +1,537 @@ +from unittest.mock import Mock, patch +from urllib.parse import urlencode +from uuid import uuid4 + +from django.core.cache import cache +from django.test import TestCase +from django.urls import reverse +from django.utils.translation import gettext as _ + +from bs4 import BeautifulSoup + +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.exceptions import ( + StreamException, + StreamTooManyException, +) +from newsreader.news.collection.twitter import TWITTER_AUTH_URL + + +class IntegrationsViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(email="test@test.nl", password="test") + self.client.force_login(self.user) + + self.url = reverse("accounts:integrations") + + +class RedditIntegrationsTestCase(IntegrationsViewTestCase): + def test_reddit_authorization(self): + self.user.reddit_refresh_token = None + self.user.save() + + response = self.client.get(self.url) + + soup = BeautifulSoup(response.content, features="lxml") + button = soup.find("a", class_="link button button--reddit") + + self.assertEquals(button.text.strip(), "Authorize account") + + def test_reddit_refresh_token(self): + self.user.reddit_refresh_token = "jadajadajada" + self.user.reddit_access_token = None + self.user.save() + + response = self.client.get(self.url) + + soup = BeautifulSoup(response.content, features="lxml") + button = soup.find("a", class_="link button button--reddit") + + self.assertEquals(button.text.strip(), "Refresh token") + + def test_reddit_revoke(self): + self.user.reddit_refresh_token = "jadajadajada" + self.user.reddit_access_token = None + self.user.save() + + response = self.client.get(self.url) + + soup = BeautifulSoup(response.content, features="lxml") + buttons = soup.find_all("a", class_="link button button--reddit") + + self.assertIn( + "Deauthorize account", [button.text.strip() for button in buttons] + ) + + +class RedditTemplateViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(email="test@test.nl", password="test") + self.client.force_login(self.user) + + self.base_url = reverse("accounts:reddit-template") + self.state = str(uuid4()) + + self.patch = patch("newsreader.news.collection.reddit.post") + self.mocked_post = self.patch.start() + + def tearDown(self): + patch.stopall() + + def test_simple(self): + response = self.client.get(self.base_url) + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Return to integrations page") + + def test_successful_authorization(self): + self.mocked_post.return_value.json.return_value = { + "access_token": "1001010412", + "refresh_token": "134510143", + } + + cache.set(f"{self.user.email}-reddit-auth", self.state) + + params = {"state": self.state, "code": "Valid code"} + url = f"{self.base_url}?{urlencode(params)}" + + response = self.client.get(url) + + self.mocked_post.assert_called_once() + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Your reddit account was successfully linked.") + + self.user.refresh_from_db() + + self.assertEquals(self.user.reddit_access_token, "1001010412") + self.assertEquals(self.user.reddit_refresh_token, "134510143") + + self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), None) + + def test_error(self): + params = {"error": "Denied authorization"} + + url = f"{self.base_url}?{urlencode(params)}" + + response = self.client.get(url) + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Denied authorization") + + def test_invalid_state(self): + cache.set(f"{self.user.email}-reddit-auth", str(uuid4())) + + params = {"code": "Valid code", "state": "Invalid state"} + + url = f"{self.base_url}?{urlencode(params)}" + + response = self.client.get(url) + + self.assertEquals(response.status_code, 200) + self.assertContains( + response, "The saved state for Reddit authorization did not match" + ) + + def test_stream_error(self): + self.mocked_post.side_effect = StreamTooManyException + + cache.set(f"{self.user.email}-reddit-auth", self.state) + + params = {"state": self.state, "code": "Valid code"} + url = f"{self.base_url}?{urlencode(params)}" + + response = self.client.get(url) + + self.mocked_post.assert_called_once() + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Too many requests") + + self.user.refresh_from_db() + + self.assertEquals(self.user.reddit_access_token, None) + self.assertEquals(self.user.reddit_refresh_token, None) + + self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), self.state) + + def test_unexpected_json(self): + self.mocked_post.return_value.json.return_value = {"message": "Happy eastern"} + + cache.set(f"{self.user.email}-reddit-auth", self.state) + + params = {"state": self.state, "code": "Valid code"} + url = f"{self.base_url}?{urlencode(params)}" + + response = self.client.get(url) + + self.mocked_post.assert_called_once() + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Access and refresh token not found in response") + + self.user.refresh_from_db() + + self.assertEquals(self.user.reddit_access_token, None) + self.assertEquals(self.user.reddit_refresh_token, None) + + self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), self.state) + + +class RedditTokenRedirectViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(email="test@test.nl", password="test") + self.client.force_login(self.user) + + self.patch = patch("newsreader.accounts.views.integrations.RedditTokenTask") + self.mocked_task = self.patch.start() + + def tearDown(self): + cache.clear() + + def test_simple(self): + response = self.client.get(reverse("accounts:reddit-refresh")) + + self.assertRedirects(response, reverse("accounts:integrations")) + + self.mocked_task.delay.assert_called_once_with(self.user.pk) + + self.assertEquals(1, cache.get(f"{self.user.email}-reddit-refresh")) + + def test_not_active(self): + cache.set(f"{self.user.email}-reddit-refresh", 1) + + response = self.client.get(reverse("accounts:reddit-refresh")) + + self.assertRedirects(response, reverse("accounts:integrations")) + + self.mocked_task.delay.assert_not_called() + + +class RedditRevokeRedirectViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(email="test@test.nl", password="test") + self.client.force_login(self.user) + + self.patch = patch("newsreader.accounts.views.integrations.revoke_reddit_token") + self.mocked_revoke = self.patch.start() + + def test_simple(self): + self.user.reddit_access_token = "jadajadajada" + self.user.reddit_refresh_token = "jadajadajada" + self.user.save() + + self.mocked_revoke.return_value = True + + response = self.client.get(reverse("accounts:reddit-revoke")) + + self.assertRedirects(response, reverse("accounts:integrations")) + + self.mocked_revoke.assert_called_once_with(self.user) + + self.user.refresh_from_db() + + self.assertEquals(self.user.reddit_access_token, None) + self.assertEquals(self.user.reddit_refresh_token, None) + + def test_no_refresh_token(self): + self.user.reddit_refresh_token = None + self.user.save() + + response = self.client.get(reverse("accounts:reddit-revoke")) + + self.assertRedirects(response, reverse("accounts:integrations")) + + self.mocked_revoke.assert_not_called() + + def test_unsuccessful_response(self): + self.user.reddit_access_token = "jadajadajada" + self.user.reddit_refresh_token = "jadajadajada" + self.user.save() + + self.mocked_revoke.return_value = False + + response = self.client.get(reverse("accounts:reddit-revoke")) + + self.assertRedirects(response, reverse("accounts:integrations")) + + self.user.refresh_from_db() + + self.assertEquals(self.user.reddit_access_token, "jadajadajada") + self.assertEquals(self.user.reddit_refresh_token, "jadajadajada") + + def test_stream_exception(self): + self.user.reddit_access_token = "jadajadajada" + self.user.reddit_refresh_token = "jadajadajada" + self.user.save() + + self.mocked_revoke.side_effect = StreamException + + response = self.client.get(reverse("accounts:reddit-revoke")) + + self.assertRedirects(response, reverse("accounts:integrations")) + + self.user.refresh_from_db() + + self.assertEquals(self.user.reddit_access_token, "jadajadajada") + self.assertEquals(self.user.reddit_refresh_token, "jadajadajada") + + +class TwitterRevokeRedirectView(TestCase): + def setUp(self): + self.user = UserFactory(email="test@test.nl", password="test") + self.client.force_login(self.user) + + self.patch = patch("newsreader.accounts.views.integrations.post") + self.mocked_post = self.patch.start() + + def tearDown(self): + patch.stopall() + + def test_simple(self): + self.user.twitter_oauth_token = "jadajadajada" + self.user.twitter_oauth_token_secret = "jadajadajada" + self.user.save() + + response = self.client.get(reverse("accounts:twitter-revoke")) + + self.assertRedirects(response, reverse("accounts:integrations")) + + self.user.refresh_from_db() + + self.assertIsNone(self.user.twitter_oauth_token) + self.assertIsNone(self.user.twitter_oauth_token_secret) + + def test_no_authorized_account(self): + self.user.twitter_oauth_token = None + self.user.twitter_oauth_token_secret = None + self.user.save() + + response = self.client.get(reverse("accounts:twitter-revoke")) + + self.assertRedirects(response, reverse("accounts:integrations")) + + self.mocked_post.assert_not_called() + + def test_stream_exception(self): + self.user.twitter_oauth_token = "jadajadajada" + self.user.twitter_oauth_token_secret = "jadajadajada" + self.user.save() + + self.mocked_post.side_effect = StreamException + + response = self.client.get(reverse("accounts:twitter-revoke")) + + self.assertRedirects(response, reverse("accounts:integrations")) + + self.user.refresh_from_db() + + self.assertEquals(self.user.twitter_oauth_token, "jadajadajada") + self.assertEquals(self.user.twitter_oauth_token_secret, "jadajadajada") + + +class TwitterAuthRedirectViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(email="test@test.nl", password="test") + self.client.force_login(self.user) + + self.patch = patch("newsreader.accounts.views.integrations.post") + self.mocked_post = self.patch.start() + + def tearDown(self): + cache.clear() + + def test_simple(self): + self.mocked_post.return_value = Mock( + text="oauth_token=foo&oauth_token_secret=bar" + ) + + response = self.client.get(reverse("accounts:twitter-auth")) + + self.assertRedirects( + response, + f"{TWITTER_AUTH_URL}/?oauth_token=foo", + fetch_redirect_response=False, + ) + + cached_token = cache.get(f"twitter-{self.user.email}-token") + cached_secret = cache.get(f"twitter-{self.user.email}-secret") + + self.assertEquals(cached_token, "foo") + self.assertEquals(cached_secret, "bar") + + def test_stream_exception(self): + self.mocked_post.side_effect = StreamException + + response = self.client.get(reverse("accounts:twitter-auth")) + + self.assertRedirects(response, reverse("accounts:integrations")) + + cached_token = cache.get(f"twitter-{self.user.email}-token") + cached_secret = cache.get(f"twitter-{self.user.email}-secret") + + self.assertIsNone(cached_token) + self.assertIsNone(cached_secret) + + def test_unexpected_contents(self): + self.mocked_post.return_value = Mock(text="foo=bar&oauth_token_secret=bar") + + response = self.client.get(reverse("accounts:twitter-auth")) + + self.assertRedirects(response, reverse("accounts:integrations")) + + cached_token = cache.get(f"twitter-{self.user.email}-token") + cached_secret = cache.get(f"twitter-{self.user.email}-secret") + + self.assertIsNone(cached_token) + self.assertIsNone(cached_secret) + + +class TwitterTemplateViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(email="test@test.nl", password="test") + self.client.force_login(self.user) + + self.patch = patch("newsreader.accounts.views.integrations.post") + self.mocked_post = self.patch.start() + + def tearDown(self): + cache.clear() + + def test_simple(self): + cache.set_many( + { + f"twitter-{self.user.email}-token": "foo", + f"twitter-{self.user.email}-secret": "bar", + } + ) + + params = {"denied": "", "oauth_token": "foo", "oauth_verifier": "barfoo"} + + self.mocked_post.return_value = Mock( + text="oauth_token=realtoken&oauth_token_secret=realsecret" + ) + + response = self.client.get( + f"{reverse('accounts:twitter-template')}?{urlencode(params)}" + ) + + self.assertContains(response, _("Twitter account is linked")) + + self.user.refresh_from_db() + + self.assertEquals(self.user.twitter_oauth_token, "realtoken") + self.assertEquals(self.user.twitter_oauth_token_secret, "realsecret") + + self.assertIsNone(cache.get(f"twitter-{self.user.email}-token")) + self.assertIsNone(cache.get(f"twitter-{self.user.email}-secret")) + + def test_denied(self): + params = {"denied": "true", "oauth_token": "foo", "oauth_verifier": "barfoo"} + + response = self.client.get( + f"{reverse('accounts:twitter-template')}?{urlencode(params)}" + ) + + self.assertContains(response, _("Twitter authorization failed")) + + self.user.refresh_from_db() + + self.assertIsNone(self.user.twitter_oauth_token) + self.assertIsNone(self.user.twitter_oauth_token_secret) + + self.mocked_post.assert_not_called() + + def test_mismatched_token(self): + cache.set_many( + { + f"twitter-{self.user.email}-token": "foo", + f"twitter-{self.user.email}-secret": "bar", + } + ) + + params = {"denied": "", "oauth_token": "boo", "oauth_verifier": "barfoo"} + + response = self.client.get( + f"{reverse('accounts:twitter-template')}?{urlencode(params)}" + ) + + self.assertContains(response, _("OAuth tokens failed to match")) + + self.user.refresh_from_db() + + self.assertIsNone(self.user.twitter_oauth_token) + self.assertIsNone(self.user.twitter_oauth_token_secret) + + self.mocked_post.assert_not_called() + + def test_missing_secret(self): + cache.set_many({f"twitter-{self.user.email}-token": "foo"}) + + params = {"denied": "", "oauth_token": "foo", "oauth_verifier": "barfoo"} + + response = self.client.get( + f"{reverse('accounts:twitter-template')}?{urlencode(params)}" + ) + + self.assertContains(response, _("No matching tokens found for this user")) + + self.user.refresh_from_db() + + self.assertIsNone(self.user.twitter_oauth_token_secret) + + self.mocked_post.assert_not_called() + + def test_stream_exception(self): + cache.set_many( + { + f"twitter-{self.user.email}-token": "foo", + f"twitter-{self.user.email}-secret": "bar", + } + ) + + params = {"denied": "", "oauth_token": "foo", "oauth_verifier": "barfoo"} + + self.mocked_post.side_effect = StreamException + + response = self.client.get( + f"{reverse('accounts:twitter-template')}?{urlencode(params)}" + ) + + self.assertContains(response, _("Failed requesting access token")) + + self.user.refresh_from_db() + + self.assertIsNone(self.user.twitter_oauth_token) + self.assertIsNone(self.user.twitter_oauth_token_secret) + + self.assertIsNotNone(cache.get(f"twitter-{self.user.email}-token")) + self.assertIsNotNone(cache.get(f"twitter-{self.user.email}-secret")) + + def test_unexpected_contents(self): + cache.set_many( + { + f"twitter-{self.user.email}-token": "foo", + f"twitter-{self.user.email}-secret": "bar", + } + ) + + params = {"denied": "", "oauth_token": "foo", "oauth_verifier": "barfoo"} + + self.mocked_post.return_value = Mock( + text="foobar=boo&oauth_token_secret=realsecret" + ) + + response = self.client.get( + f"{reverse('accounts:twitter-template')}?{urlencode(params)}" + ) + + self.assertContains(response, _("No credentials found in Twitter response")) + + self.user.refresh_from_db() + + self.assertIsNone(self.user.twitter_oauth_token) + self.assertIsNone(self.user.twitter_oauth_token_secret) + + self.assertIsNotNone(cache.get(f"twitter-{self.user.email}-token")) + self.assertIsNotNone(cache.get(f"twitter-{self.user.email}-secret")) diff --git a/src/newsreader/accounts/tests/test_settings.py b/src/newsreader/accounts/tests/test_settings.py index d093ea4..42db736 100644 --- a/src/newsreader/accounts/tests/test_settings.py +++ b/src/newsreader/accounts/tests/test_settings.py @@ -1,14 +1,8 @@ -from unittest.mock import patch -from urllib.parse import urlencode -from uuid import uuid4 - -from django.core.cache import cache from django.test import TestCase from django.urls import reverse from newsreader.accounts.models import User from newsreader.accounts.tests.factories import UserFactory -from newsreader.news.collection.exceptions import StreamTooManyException class SettingsViewTestCase(TestCase): @@ -22,7 +16,6 @@ class SettingsViewTestCase(TestCase): response = self.client.get(self.url) self.assertEquals(response.status_code, 200) - self.assertContains(response, "Authorize Reddit account") def test_user_credential_change(self): response = self.client.post( @@ -36,126 +29,3 @@ class SettingsViewTestCase(TestCase): self.assertEquals(user.first_name, "First name") self.assertEquals(user.last_name, "Last name") - - def test_linked_reddit_account(self): - self.user.reddit_refresh_token = "test" - self.user.save() - - response = self.client.get(self.url) - - self.assertEquals(response.status_code, 200) - self.assertNotContains(response, "Authorize Reddit account") - - -class RedditTemplateViewTestCase(TestCase): - def setUp(self): - self.user = UserFactory(email="test@test.nl", password="test") - self.client.force_login(self.user) - - self.base_url = reverse("accounts:reddit-template") - self.state = str(uuid4()) - - self.patch = patch("newsreader.news.collection.reddit.post") - self.mocked_post = self.patch.start() - - def tearDown(self): - patch.stopall() - - def test_simple(self): - response = self.client.get(self.base_url) - - self.assertEquals(response.status_code, 200) - self.assertContains(response, "Return to settings page") - - def test_successful_authorization(self): - self.mocked_post.return_value.json.return_value = { - "access_token": "1001010412", - "refresh_token": "134510143", - } - - cache.set(f"{self.user.email}-reddit-auth", self.state) - - params = {"state": self.state, "code": "Valid code"} - url = f"{self.base_url}?{urlencode(params)}" - - response = self.client.get(url) - - self.mocked_post.assert_called_once() - - self.assertEquals(response.status_code, 200) - self.assertContains(response, "Your reddit account was successfully linked.") - - self.user.refresh_from_db() - - self.assertEquals(self.user.reddit_access_token, "1001010412") - self.assertEquals(self.user.reddit_refresh_token, "134510143") - - self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), None) - - def test_error(self): - params = {"error": "Denied authorization"} - - url = f"{self.base_url}?{urlencode(params)}" - - response = self.client.get(url) - - self.assertEquals(response.status_code, 200) - self.assertContains(response, "Denied authorization") - - def test_invalid_state(self): - cache.set(f"{self.user.email}-reddit-auth", str(uuid4())) - - params = {"code": "Valid code", "state": "Invalid state"} - - url = f"{self.base_url}?{urlencode(params)}" - - response = self.client.get(url) - - self.assertEquals(response.status_code, 200) - self.assertContains( - response, "The saved state for Reddit authorization did not match" - ) - - def test_stream_error(self): - self.mocked_post.side_effect = StreamTooManyException - - cache.set(f"{self.user.email}-reddit-auth", self.state) - - params = {"state": self.state, "code": "Valid code"} - url = f"{self.base_url}?{urlencode(params)}" - - response = self.client.get(url) - - self.mocked_post.assert_called_once() - - self.assertEquals(response.status_code, 200) - self.assertContains(response, "Too many requests") - - self.user.refresh_from_db() - - self.assertEquals(self.user.reddit_access_token, None) - self.assertEquals(self.user.reddit_refresh_token, None) - - self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), self.state) - - def test_unexpected_json(self): - self.mocked_post.return_value.json.return_value = {"message": "Happy eastern"} - - cache.set(f"{self.user.email}-reddit-auth", self.state) - - params = {"state": self.state, "code": "Valid code"} - url = f"{self.base_url}?{urlencode(params)}" - - response = self.client.get(url) - - self.mocked_post.assert_called_once() - - self.assertEquals(response.status_code, 200) - self.assertContains(response, "Access and refresh token not found in response") - - self.user.refresh_from_db() - - self.assertEquals(self.user.reddit_access_token, None) - self.assertEquals(self.user.reddit_refresh_token, None) - - self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), self.state) diff --git a/src/newsreader/accounts/tests/tests.py b/src/newsreader/accounts/tests/tests.py index e28dbd3..9f6a20f 100644 --- a/src/newsreader/accounts/tests/tests.py +++ b/src/newsreader/accounts/tests/tests.py @@ -1,22 +1,24 @@ from django.test import TestCase -from django_celery_beat.models import PeriodicTask +from django_celery_beat.models import IntervalSchedule, PeriodicTask -from newsreader.accounts.models import User +from newsreader.accounts.tests.factories import UserFactory class UserTestCase(TestCase): - def test_task_is_created(self): - user = User.objects.create(email="durp@burp.nl", task=None) - task = PeriodicTask.objects.get(name=f"{user.email}-collection-task") - - user.refresh_from_db() - - self.assertEquals(task, user.task) - self.assertEquals(PeriodicTask.objects.count(), 1) - def test_task_is_deleted(self): - user = User.objects.create(email="durp@burp.nl", task=None) + user = UserFactory(email="durp@burp.nl") + + interval = IntervalSchedule.objects.create( + every=1, period=IntervalSchedule.HOURS + ) + PeriodicTask.objects.create( + name=f"{user.email}-feed", task="FeedTask", interval=interval + ) + PeriodicTask.objects.create( + name=f"{user.email}-timeline", task="TwitterTimelineTask", interval=interval + ) + user.delete() self.assertEquals(PeriodicTask.objects.count(), 0) diff --git a/src/newsreader/accounts/urls.py b/src/newsreader/accounts/urls.py index 672cf6d..3cdd1b1 100644 --- a/src/newsreader/accounts/urls.py +++ b/src/newsreader/accounts/urls.py @@ -5,6 +5,7 @@ from newsreader.accounts.views import ( ActivationCompleteView, ActivationResendView, ActivationView, + IntegrationsView, LoginView, LogoutView, PasswordChangeView, @@ -12,18 +13,24 @@ from newsreader.accounts.views import ( PasswordResetConfirmView, PasswordResetDoneView, PasswordResetView, + RedditRevokeRedirectView, RedditTemplateView, RedditTokenRedirectView, RegistrationClosedView, RegistrationCompleteView, RegistrationView, SettingsView, + TwitterAuthRedirectView, + TwitterRevokeRedirectView, + TwitterTemplateView, ) urlpatterns = [ + # Auth path("login/", LoginView.as_view(), name="login"), path("logout/", LogoutView.as_view(), name="logout"), + # Register path("register/", RegistrationView.as_view(), name="register"), path( "register/complete/", @@ -41,6 +48,7 @@ urlpatterns = [ ActivationView.as_view(), name="activate", ), + # Password path("password-reset/", PasswordResetView.as_view(), name="password-reset"), path( "password-reset/done/", @@ -62,15 +70,42 @@ urlpatterns = [ login_required(PasswordChangeView.as_view()), name="password-change", ), - path("settings/", login_required(SettingsView.as_view()), name="settings"), + # Integrations path( - "settings/reddit/callback/", + "settings/integrations/reddit/callback/", login_required(RedditTemplateView.as_view()), name="reddit-template", ), path( - "settings/reddit/refresh/", + "settings/integrations/reddit/refresh/", login_required(RedditTokenRedirectView.as_view()), name="reddit-refresh", ), + path( + "settings/integrations/reddit/revoke/", + login_required(RedditRevokeRedirectView.as_view()), + name="reddit-revoke", + ), + path( + "settings/integrations/twitter/auth/", + login_required(TwitterAuthRedirectView.as_view()), + name="twitter-auth", + ), + path( + "settings/integrations/twitter/callback/", + login_required(TwitterTemplateView.as_view()), + name="twitter-template", + ), + path( + "settings/integrations/twitter/revoke/", + login_required(TwitterRevokeRedirectView.as_view()), + name="twitter-revoke", + ), + path( + "settings/integrations", + login_required(IntegrationsView.as_view()), + name="integrations", + ), + # Settings + path("settings/", login_required(SettingsView.as_view()), name="settings"), ] diff --git a/src/newsreader/accounts/views.py b/src/newsreader/accounts/views.py deleted file mode 100644 index 4f982a9..0000000 --- a/src/newsreader/accounts/views.py +++ /dev/null @@ -1,210 +0,0 @@ -from django.contrib import messages -from django.contrib.auth import views as django_views -from django.core.cache import cache -from django.shortcuts import render -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from django.views.generic import RedirectView, TemplateView -from django.views.generic.edit import FormView, ModelFormMixin - -from registration.backends.default import views as registration_views - -from newsreader.accounts.forms import UserSettingsForm -from newsreader.accounts.models import User -from newsreader.news.collection.exceptions import StreamException -from newsreader.news.collection.reddit import ( - get_reddit_access_token, - get_reddit_authorization_url, -) -from newsreader.news.collection.tasks import RedditTokenTask - - -class LoginView(django_views.LoginView): - template_name = "accounts/views/login.html" - success_url = reverse_lazy("index") - - -class LogoutView(django_views.LogoutView): - next_page = reverse_lazy("accounts:login") - - -# RegistrationView shows a registration form and sends the email -# RegistrationCompleteView shows after filling in the registration form -# ActivationView is send within the activation email and activates the account -# ActivationCompleteView shows the success screen when activation was succesful -# ActivationResendView can be used when activation links are expired -# RegistrationClosedView shows when registration is disabled -class RegistrationView(registration_views.RegistrationView): - disallowed_url = reverse_lazy("accounts:register-closed") - template_name = "registration/registration_form.html" - success_url = reverse_lazy("accounts:register-complete") - - -class RegistrationCompleteView(TemplateView): - template_name = "registration/registration_complete.html" - - -class RegistrationClosedView(TemplateView): - template_name = "registration/registration_closed.html" - - -# Redirects or renders failed activation template -class ActivationView(registration_views.ActivationView): - template_name = "registration/activation_failure.html" - - def get_success_url(self, user): - return ("accounts:activate-complete", (), {}) - - -class ActivationCompleteView(TemplateView): - template_name = "registration/activation_complete.html" - - -# Renders activation form resend or resend_activation_complete -class ActivationResendView(registration_views.ResendActivationView): - template_name = "registration/activation_resend_form.html" - - def render_form_submitted_template(self, form): - """ - Renders resend activation complete template with the submitted email. - - """ - email = form.cleaned_data["email"] - context = {"email": email} - - return render( - self.request, "registration/activation_resend_complete.html", context - ) - - -# PasswordResetView sends the mail -# PasswordResetDoneView shows a success message for the above -# PasswordResetConfirmView checks the link the user clicked and -# prompts for a new password -# PasswordResetCompleteView shows a success message for the above -class PasswordResetView(django_views.PasswordResetView): - template_name = "password-reset/password-reset.html" - subject_template_name = "password-reset/password-reset-subject.txt" - email_template_name = "password-reset/password-reset-email.html" - success_url = reverse_lazy("accounts:password-reset-done") - - -class PasswordResetDoneView(django_views.PasswordResetDoneView): - template_name = "password-reset/password-reset-done.html" - - -class PasswordResetConfirmView(django_views.PasswordResetConfirmView): - template_name = "password-reset/password-reset-confirm.html" - success_url = reverse_lazy("accounts:password-reset-complete") - - -class PasswordResetCompleteView(django_views.PasswordResetCompleteView): - template_name = "password-reset/password-reset-complete.html" - - -class PasswordChangeView(django_views.PasswordChangeView): - template_name = "accounts/views/password-change.html" - success_url = reverse_lazy("accounts:settings") - - -class SettingsView(ModelFormMixin, FormView): - template_name = "accounts/views/settings.html" - success_url = reverse_lazy("accounts:settings") - form_class = UserSettingsForm - model = User - - def get(self, request, *args, **kwargs): - self.object = self.get_object() - return super().get(request, *args, **kwargs) - - def get_object(self, **kwargs): - return self.request.user - - def get_context_data(self, **kwargs): - user = self.request.user - - reddit_authorization_url = None - reddit_refresh_url = None - reddit_task_active = cache.get(f"{user.email}-reddit-refresh") - - if ( - user.reddit_refresh_token - and not user.reddit_access_token - and not reddit_task_active - ): - reddit_refresh_url = reverse_lazy("accounts:reddit-refresh") - - if not user.reddit_refresh_token: - reddit_authorization_url = get_reddit_authorization_url(user) - - return { - **super().get_context_data(**kwargs), - "reddit_authorization_url": reddit_authorization_url, - "reddit_refresh_url": reddit_refresh_url, - } - - def get_form_kwargs(self): - return {**super().get_form_kwargs(), "instance": self.request.user} - - -class RedditTemplateView(TemplateView): - template_name = "accounts/views/reddit.html" - - def get(self, request, *args, **kwargs): - context = self.get_context_data(**kwargs) - - error = request.GET.get("error", None) - state = request.GET.get("state", None) - code = request.GET.get("code", None) - - if error: - return self.render_to_response({**context, "error": error}) - - if not code or not state: - return self.render_to_response(context) - - cached_state = cache.get(f"{request.user.email}-reddit-auth") - - if state != cached_state: - return self.render_to_response( - { - **context, - "error": "The saved state for Reddit authorization did not match", - } - ) - - try: - access_token, refresh_token = get_reddit_access_token(code, request.user) - - return self.render_to_response( - { - **context, - "access_token": access_token, - "refresh_token": refresh_token, - } - ) - except StreamException as e: - return self.render_to_response({**context, "error": str(e)}) - except KeyError: - return self.render_to_response( - {**context, "error": "Access and refresh token not found in response"} - ) - - -class RedditTokenRedirectView(RedirectView): - url = reverse_lazy("accounts:settings") - - def get(self, request, *args, **kwargs): - response = super().get(request, *args, **kwargs) - - user = request.user - task_active = cache.get(f"{user.email}-reddit-refresh") - - if not task_active: - RedditTokenTask.delay(user.pk) - messages.success(request, _("Access token is being retrieved")) - cache.set(f"{user.email}-reddit-refresh", 1, 300) - return response - - messages.error(request, _("Unable to retrieve token")) - return response diff --git a/src/newsreader/accounts/views/__init__.py b/src/newsreader/accounts/views/__init__.py new file mode 100644 index 0000000..81dd1fc --- /dev/null +++ b/src/newsreader/accounts/views/__init__.py @@ -0,0 +1,26 @@ +from newsreader.accounts.views.auth import LoginView, LogoutView +from newsreader.accounts.views.integrations import ( + IntegrationsView, + RedditRevokeRedirectView, + RedditTemplateView, + RedditTokenRedirectView, + TwitterAuthRedirectView, + TwitterRevokeRedirectView, + TwitterTemplateView, +) +from newsreader.accounts.views.password import ( + PasswordChangeView, + PasswordResetCompleteView, + PasswordResetConfirmView, + PasswordResetDoneView, + PasswordResetView, +) +from newsreader.accounts.views.registration import ( + ActivationCompleteView, + ActivationResendView, + ActivationView, + RegistrationClosedView, + RegistrationCompleteView, + RegistrationView, +) +from newsreader.accounts.views.settings import SettingsView diff --git a/src/newsreader/accounts/views/auth.py b/src/newsreader/accounts/views/auth.py new file mode 100644 index 0000000..0663768 --- /dev/null +++ b/src/newsreader/accounts/views/auth.py @@ -0,0 +1,11 @@ +from django.contrib.auth import views as django_views +from django.urls import reverse_lazy + + +class LoginView(django_views.LoginView): + template_name = "accounts/views/login.html" + success_url = reverse_lazy("index") + + +class LogoutView(django_views.LogoutView): + next_page = reverse_lazy("accounts:login") diff --git a/src/newsreader/accounts/views/integrations.py b/src/newsreader/accounts/views/integrations.py new file mode 100644 index 0000000..62d71fc --- /dev/null +++ b/src/newsreader/accounts/views/integrations.py @@ -0,0 +1,343 @@ +import logging + +from urllib.parse import parse_qs, urlencode + +from django.conf import settings +from django.contrib import messages +from django.core.cache import cache +from django.shortcuts import redirect +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from django.views.generic import RedirectView, TemplateView + +from requests_oauthlib import OAuth1 as OAuth + +from newsreader.news.collection.exceptions import StreamException +from newsreader.news.collection.reddit import ( + get_reddit_access_token, + get_reddit_authorization_url, + revoke_reddit_token, +) +from newsreader.news.collection.tasks import RedditTokenTask +from newsreader.news.collection.twitter import ( + TWITTER_ACCESS_TOKEN_URL, + TWITTER_AUTH_URL, + TWITTER_REQUEST_TOKEN_URL, + TWITTER_REVOKE_URL, +) +from newsreader.news.collection.utils import post + + +logger = logging.getLogger(__name__) + + +class IntegrationsView(TemplateView): + template_name = "accounts/views/integrations.html" + + def get_context_data(self, **kwargs): + return { + **super().get_context_data(**kwargs), + **self.get_reddit_context(**kwargs), + **self.get_twitter_context(**kwargs), + } + + def get_reddit_context(self, **kwargs): + user = self.request.user + reddit_authorization_url = None + reddit_refresh_url = None + + reddit_task_active = cache.get(f"{user.email}-reddit-refresh") + + if ( + user.reddit_refresh_token + and not user.reddit_access_token + and not reddit_task_active + ): + reddit_refresh_url = reverse_lazy("accounts:reddit-refresh") + + if not user.reddit_refresh_token: + reddit_authorization_url = get_reddit_authorization_url(user) + + return { + "reddit_authorization_url": reddit_authorization_url, + "reddit_refresh_url": reddit_refresh_url, + "reddit_revoke_url": ( + reverse_lazy("accounts:reddit-revoke") + if not reddit_authorization_url + else None + ), + } + + def get_twitter_context(self, **kwargs): + twitter_revoke_url = None + + if self.request.user.has_twitter_auth: + twitter_revoke_url = reverse_lazy("accounts:twitter-revoke") + + return { + "twitter_auth_url": reverse_lazy("accounts:twitter-auth"), + "twitter_revoke_url": twitter_revoke_url, + } + + +class RedditTemplateView(TemplateView): + template_name = "accounts/views/reddit.html" + + def get(self, request, *args, **kwargs): + context = self.get_context_data(**kwargs) + + error = request.GET.get("error", None) + state = request.GET.get("state", None) + code = request.GET.get("code", None) + + if error: + return self.render_to_response({**context, "error": error}) + + if not code or not state: + return self.render_to_response(context) + + cached_state = cache.get(f"{request.user.email}-reddit-auth") + + if state != cached_state: + return self.render_to_response( + { + **context, + "error": _( + "The saved state for Reddit authorization did not match" + ), + } + ) + + try: + access_token, refresh_token = get_reddit_access_token(code, request.user) + + return self.render_to_response( + { + **context, + "access_token": access_token, + "refresh_token": refresh_token, + } + ) + except StreamException as e: + return self.render_to_response({**context, "error": str(e)}) + except KeyError: + return self.render_to_response( + { + **context, + "error": _("Access and refresh token not found in response"), + } + ) + + +class RedditTokenRedirectView(RedirectView): + url = reverse_lazy("accounts:integrations") + + def get(self, request, *args, **kwargs): + response = super().get(request, *args, **kwargs) + + user = request.user + task_active = cache.get(f"{user.email}-reddit-refresh") + + if not task_active: + RedditTokenTask.delay(user.pk) + messages.success(request, _("Access token is being retrieved")) + cache.set(f"{user.email}-reddit-refresh", 1, 300) + return response + + messages.error(request, _("Unable to retrieve token")) + return response + + +class RedditRevokeRedirectView(RedirectView): + url = reverse_lazy("accounts:integrations") + + def get(self, request, *args, **kwargs): + response = super().get(request, *args, **kwargs) + + user = request.user + + if not user.reddit_refresh_token: + messages.error(request, _("No reddit account is linked to this account")) + return response + + try: + is_revoked = revoke_reddit_token(user) + except StreamException: + logger.exception(f"Unable to revoke reddit token for {user.pk}") + + messages.error(request, _("Unable to revoke reddit token")) + return response + + if not is_revoked: + messages.error(request, _("Unable to revoke reddit token")) + return response + + user.reddit_access_token = None + user.reddit_refresh_token = None + user.save() + + messages.success(request, _("Reddit account deathorized")) + return response + + +class TwitterRevokeRedirectView(RedirectView): + url = reverse_lazy("accounts:integrations") + + def get(self, request, *args, **kwargs): + if not request.user.has_twitter_auth: + messages.error(request, _("No twitter credentials found")) + return super().get(request, *args, **kwargs) + + oauth = OAuth( + settings.TWITTER_CONSUMER_ID, + client_secret=settings.TWITTER_CONSUMER_SECRET, + resource_owner_key=request.user.twitter_oauth_token, + resource_owner_secret=request.user.twitter_oauth_token_secret, + ) + + try: + post(TWITTER_REVOKE_URL, auth=oauth) + except StreamException: + logger.exception("Failed revoking Twitter account") + + messages.error(request, _("Unable revoke Twitter account")) + return super().get(request, *args, **kwargs) + + request.user.twitter_oauth_token = None + request.user.twitter_oauth_token_secret = None + request.user.save() + + messages.success(request, _("Twitter account revoked")) + return super().get(request, *args, **kwargs) + + +class TwitterAuthRedirectView(RedirectView): + url = reverse_lazy("accounts:integrations") + + def get(self, request, *args, **kwargs): + oauth = OAuth( + settings.TWITTER_CONSUMER_ID, + client_secret=settings.TWITTER_CONSUMER_SECRET, + callback_uri=settings.TWITTER_REDIRECT_URL, + ) + + try: + response = post(TWITTER_REQUEST_TOKEN_URL, auth=oauth) + except StreamException: + logger.exception("Failed requesting Twitter authentication token") + + messages.error(request, _("Unable to retrieve initial Twitter token")) + return super().get(request, *args, **kwargs) + + params = parse_qs(response.text) + + try: + request_oauth_token = params["oauth_token"][0] + request_oauth_secret = params["oauth_token_secret"][0] + except KeyError: + logger.exception("No credentials found in response") + + messages.error(request, _("Unable to retrieve initial Twitter token")) + return super().get(request, *args, **kwargs) + + cache.set_many( + { + f"twitter-{request.user.email}-token": request_oauth_token, + f"twitter-{request.user.email}-secret": request_oauth_secret, + } + ) + + request_params = urlencode({"oauth_token": request_oauth_token}) + return redirect(f"{TWITTER_AUTH_URL}/?{request_params}") + + +class TwitterTemplateView(TemplateView): + template_name = "accounts/views/twitter.html" + + def get(self, request, *args, **kwargs): + context = self.get_context_data(**kwargs) + + denied = request.GET.get("denied", False) + oauth_token = request.GET.get("oauth_token") + oauth_verifier = request.GET.get("oauth_verifier") + + if denied: + return self.render_to_response( + { + **context, + "error": _("Twitter authorization failed"), + "authorized": False, + } + ) + + cached_token = cache.get(f"twitter-{request.user.email}-token") + + if oauth_token != cached_token: + return self.render_to_response( + { + **context, + "error": _("OAuth tokens failed to match"), + "authorized": False, + } + ) + + cached_secret = cache.get(f"twitter-{request.user.email}-secret") + + if not cached_token or not cached_secret: + return self.render_to_response( + { + **context, + "error": _("No matching tokens found for this user"), + "authorized": False, + } + ) + + oauth = OAuth( + settings.TWITTER_CONSUMER_ID, + client_secret=settings.TWITTER_CONSUMER_SECRET, + resource_owner_key=cached_token, + resource_owner_secret=cached_secret, + verifier=oauth_verifier, + ) + + try: + response = post(TWITTER_ACCESS_TOKEN_URL, auth=oauth) + except StreamException: + logger.exception("Failed requesting Twitter access token") + + return self.render_to_response( + { + **context, + "error": _("Failed requesting access token"), + "authorized": False, + } + ) + + params = parse_qs(response.text) + + try: + oauth_token = params["oauth_token"][0] + oauth_secret = params["oauth_token_secret"][0] + except KeyError: + logger.exception("No credentials in Twitter response") + + return self.render_to_response( + { + **context, + "error": _("No credentials found in Twitter response"), + "authorized": False, + } + ) + + request.user.twitter_oauth_token = oauth_token + request.user.twitter_oauth_token_secret = oauth_secret + request.user.save() + + cache.delete_many( + [ + f"twitter-{request.user.email}-token", + f"twitter-{request.user.email}-secret", + ] + ) + + return self.render_to_response({**context, "error": None, "authorized": True}) diff --git a/src/newsreader/accounts/views/password.py b/src/newsreader/accounts/views/password.py new file mode 100644 index 0000000..e9e0aa3 --- /dev/null +++ b/src/newsreader/accounts/views/password.py @@ -0,0 +1,37 @@ +from django.contrib.auth import views as django_views +from django.urls import reverse_lazy + +from newsreader.news.collection.reddit import ( + get_reddit_access_token, + get_reddit_authorization_url, +) + + +# PasswordResetView sends the mail +# PasswordResetDoneView shows a success message for the above +# PasswordResetConfirmView checks the link the user clicked and +# prompts for a new password +# PasswordResetCompleteView shows a success message for the above +class PasswordResetView(django_views.PasswordResetView): + template_name = "password-reset/password-reset.html" + subject_template_name = "password-reset/password-reset-subject.txt" + email_template_name = "password-reset/password-reset-email.html" + success_url = reverse_lazy("accounts:password-reset-done") + + +class PasswordResetDoneView(django_views.PasswordResetDoneView): + template_name = "password-reset/password-reset-done.html" + + +class PasswordResetConfirmView(django_views.PasswordResetConfirmView): + template_name = "password-reset/password-reset-confirm.html" + success_url = reverse_lazy("accounts:password-reset-complete") + + +class PasswordResetCompleteView(django_views.PasswordResetCompleteView): + template_name = "password-reset/password-reset-complete.html" + + +class PasswordChangeView(django_views.PasswordChangeView): + template_name = "accounts/views/password-change.html" + success_url = reverse_lazy("accounts:settings") diff --git a/src/newsreader/accounts/views/registration.py b/src/newsreader/accounts/views/registration.py new file mode 100644 index 0000000..597aa9a --- /dev/null +++ b/src/newsreader/accounts/views/registration.py @@ -0,0 +1,59 @@ +from django.shortcuts import render +from django.urls import reverse_lazy +from django.views.generic import TemplateView + +from registration.backends.default import views as registration_views + +from newsreader.news.collection.reddit import ( + get_reddit_access_token, + get_reddit_authorization_url, +) + + +# RegistrationView shows a registration form and sends the email +# RegistrationCompleteView shows after filling in the registration form +# ActivationView is send within the activation email and activates the account +# ActivationCompleteView shows the success screen when activation was succesful +# ActivationResendView can be used when activation links are expired +# RegistrationClosedView shows when registration is disabled +class RegistrationView(registration_views.RegistrationView): + disallowed_url = reverse_lazy("accounts:register-closed") + template_name = "registration/registration_form.html" + success_url = reverse_lazy("accounts:register-complete") + + +class RegistrationCompleteView(TemplateView): + template_name = "registration/registration_complete.html" + + +class RegistrationClosedView(TemplateView): + template_name = "registration/registration_closed.html" + + +# Redirects or renders failed activation template +class ActivationView(registration_views.ActivationView): + template_name = "registration/activation_failure.html" + + def get_success_url(self, user): + return ("accounts:activate-complete", (), {}) + + +class ActivationCompleteView(TemplateView): + template_name = "registration/activation_complete.html" + + +# Renders activation form resend or resend_activation_complete +class ActivationResendView(registration_views.ResendActivationView): + template_name = "registration/activation_resend_form.html" + + def render_form_submitted_template(self, form): + """ + Renders resend activation complete template with the submitted email. + + """ + email = form.cleaned_data["email"] + context = {"email": email} + + return render( + self.request, "registration/activation_resend_complete.html", context + ) diff --git a/src/newsreader/accounts/views/settings.py b/src/newsreader/accounts/views/settings.py new file mode 100644 index 0000000..1603252 --- /dev/null +++ b/src/newsreader/accounts/views/settings.py @@ -0,0 +1,26 @@ +from django.urls import reverse_lazy +from django.views.generic.edit import FormView, ModelFormMixin + +from newsreader.accounts.forms import UserSettingsForm +from newsreader.accounts.models import User +from newsreader.news.collection.reddit import ( + get_reddit_access_token, + get_reddit_authorization_url, +) + + +class SettingsView(ModelFormMixin, FormView): + template_name = "accounts/views/settings.html" + success_url = reverse_lazy("accounts:settings") + form_class = UserSettingsForm + model = User + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + return super().get(request, *args, **kwargs) + + def get_object(self, **kwargs): + return self.request.user + + def get_form_kwargs(self): + return {**super().get_form_kwargs(), "instance": self.request.user} diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 43b89fd..d41f352 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -129,19 +129,14 @@ LOGGING = { "class": "logging.StreamHandler", "formatter": "timestamped", }, - "mail_admins": { - "level": "ERROR", - "filters": ["require_debug_false"], - "class": "django.utils.log.AdminEmailHandler", - }, - "syslog": { + "celery": { "level": "INFO", "filters": ["require_debug_false"], "class": "logging.handlers.SysLogHandler", "formatter": "syslog", "address": "/dev/log", }, - "syslog_errors": { + "syslog": { "level": "ERROR", "filters": ["require_debug_false"], "class": "logging.handlers.SysLogHandler", @@ -150,26 +145,13 @@ LOGGING = { }, }, "loggers": { - "django": { - "handlers": ["console", "mail_admins", "syslog_errors"], - "level": "WARNING", - }, + "django": {"handlers": ["console", "syslog"], "level": "INFO"}, "django.server": { - "handlers": ["console", "syslog_errors"], - "level": "INFO", - "propagate": False, - }, - "django.request": { - "handlers": ["console", "syslog_errors"], - "level": "INFO", - "propagate": False, - }, - "celery": {"handlers": ["syslog", "console"], "level": "INFO"}, - "celery.task": { - "handlers": ["syslog", "console"], + "handlers": ["console", "syslog"], "level": "INFO", "propagate": False, }, + "celery": {"handlers": ["celery", "console"], "level": "INFO"}, "newsreader": {"handlers": ["syslog", "console"], "level": "INFO"}, }, } @@ -219,7 +201,16 @@ VERSION = get_current_version() # Reddit integration REDDIT_CLIENT_ID = "CLIENT_ID" REDDIT_CLIENT_SECRET = "CLIENT_SECRET" -REDDIT_REDIRECT_URL = "http://127.0.0.1:8000/accounts/settings/reddit/callback/" +REDDIT_REDIRECT_URL = ( + "http://127.0.0.1:8000/accounts/settings/integrations/reddit/callback/" +) + +# Twitter integration +TWITTER_CONSUMER_ID = "CONSUMER_ID" +TWITTER_CONSUMER_SECRET = "CONSUMER_SECRET" +TWITTER_REDIRECT_URL = ( + "http://127.0.0.1:8000/accounts/settings/integrations/twitter/callback/" +) # Third party settings AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler" diff --git a/src/newsreader/conf/production.py b/src/newsreader/conf/production.py index bfe9818..f481885 100644 --- a/src/newsreader/conf/production.py +++ b/src/newsreader/conf/production.py @@ -46,9 +46,14 @@ TEMPLATES = [ ] # Reddit integration -REDDIT_CLIENT_ID = os.environ["REDDIT_CLIENT_ID"] -REDDIT_CLIENT_SECRET = os.environ["REDDIT_CLIENT_SECRET"] -REDDIT_REDIRECT_URL = os.environ["REDDIT_CALLBACK_URL"] +REDDIT_CLIENT_ID = os.environ.get("REDDIT_CLIENT_ID", "") +REDDIT_CLIENT_SECRET = os.environ.get("REDDIT_CLIENT_SECRET", "") +REDDIT_REDIRECT_URL = os.environ.get("REDDIT_CALLBACK_URL", "") + +# Twitter integration +TWITTER_CONSUMER_ID = os.environ.get("TWITTER_CONSUMER_ID", "") +TWITTER_CONSUMER_SECRET = os.environ.get("TWITTER_CONSUMER_SECRET", "") +TWITTER_REDIRECT_URL = os.environ.get("TWITTER_REDIRECT_URL", "") # Third party settings AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler" diff --git a/src/newsreader/fixtures/default-fixture.json b/src/newsreader/fixtures/default-fixture.json index 10d6416..1794742 100644 --- a/src/newsreader/fixtures/default-fixture.json +++ b/src/newsreader/fixtures/default-fixture.json @@ -1,4023 +1,4023 @@ -[ -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "admin", - "model": "logentry" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "auth", - "model": "permission" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "auth", - "model": "group" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "contenttypes", - "model": "contenttype" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "sessions", - "model": "session" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "django_celery_beat", - "model": "crontabschedule" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "django_celery_beat", - "model": "intervalschedule" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "django_celery_beat", - "model": "periodictask" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "django_celery_beat", - "model": "periodictasks" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "django_celery_beat", - "model": "solarschedule" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "django_celery_beat", - "model": "clockedschedule" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "registration", - "model": "registrationprofile" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "registration", - "model": "supervisedregistrationprofile" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "axes", - "model": "accessattempt" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "axes", - "model": "accesslog" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "accounts", - "model": "user" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "core", - "model": "post" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "core", - "model": "category" - } -}, -{ - "model": "contenttypes.contenttype", - "fields": { - "app_label": "collection", - "model": "collectionrule" - } -}, -{ - "model": "sessions.session", - "pk": "3sumq22krk8tsvexcs4b8czu82yhvuer", - "fields": { - "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", - "expire_date": "2020-05-16T18:29:04.049Z" - } -}, -{ - "model": "sessions.session", - "pk": "8ix6bdwf2ywk0eir1hb062dhfh9xit85", - "fields": { - "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", - "expire_date": "2020-07-21T19:36:54.530Z" - } -}, -{ - "model": "sessions.session", - "pk": "d4wophwpjm8z96doe8iddvhdv9yfafyx", - "fields": { - "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", - "expire_date": "2020-06-07T19:45:49.727Z" - } -}, -{ - "model": "sessions.session", - "pk": "g23ziz66li5zx8nd8cewb3vevdxhjkm0", - "fields": { - "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", - "expire_date": "2020-06-30T06:55:50.747Z" - } -}, -{ - "model": "sessions.session", - "pk": "jwn66dptmdkm6hom2ns3j288aaxqtyjd", - "fields": { - "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", - "expire_date": "2020-06-07T18:38:19.116Z" - } -}, -{ - "model": "sessions.session", - "pk": "wjz6kwg5e5ciemre0l0wwyrcwcj2gyg6", - "fields": { - "session_data": "MWU5ODBjY2QyOTFhMmRiY2QyYjQwZjQ3MmMwYmExYjBlYTkxNTcwODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiI0YWZkYTkxNzU5ZDBhZDZmMjg1ZTQyOGY0OTUxN2M5MTJhMmM5NWIyIn0=", - "expire_date": "2020-08-09T09:52:04.705Z" - } -}, -{ - "model": "django_celery_beat.intervalschedule", - "pk": 1, - "fields": { - "every": 5, - "period": "minutes" - } -}, -{ - "model": "django_celery_beat.intervalschedule", - "pk": 2, - "fields": { - "every": 15, - "period": "minutes" - } -}, -{ - "model": "django_celery_beat.intervalschedule", - "pk": 3, - "fields": { - "every": 30, - "period": "minutes" - } -}, -{ - "model": "django_celery_beat.intervalschedule", - "pk": 4, - "fields": { - "every": 1, - "period": "hours" - } -}, -{ - "model": "django_celery_beat.intervalschedule", - "pk": 5, - "fields": { - "every": 4, - "period": "hours" - } -}, -{ - "model": "django_celery_beat.crontabschedule", - "pk": 1, - "fields": { - "minute": "0", - "hour": "4", - "day_of_week": "*", - "day_of_month": "*", - "month_of_year": "*", - "timezone": "UTC" - } -}, -{ - "model": "django_celery_beat.periodictasks", - "pk": 1, - "fields": { - "last_update": "2020-07-26T09:47:48.298Z" - } -}, -{ - "model": "django_celery_beat.periodictask", - "pk": 1, - "fields": { - "name": "celery.backend_cleanup", - "task": "celery.backend_cleanup", - "interval": null, - "crontab": 1, - "solar": null, - "clocked": null, - "args": "[]", - "kwargs": "{}", - "queue": null, - "exchange": null, - "routing_key": null, - "headers": "{}", - "priority": null, - "expires": null, - "expire_seconds": 43200, - "one_off": false, - "start_time": null, - "enabled": true, - "last_run_at": "2020-07-26T09:47:48.322Z", - "total_run_count": 17, - "date_changed": "2020-07-26T09:47:50.362Z", - "description": "" - } -}, -{ - "model": "django_celery_beat.periodictask", - "pk": 10, - "fields": { - "name": "sonny@bakker.nl-collection-task", - "task": "FeedTask", - "interval": 5, - "crontab": null, - "solar": null, - "clocked": null, - "args": "[1]", - "kwargs": "{}", - "queue": null, - "exchange": null, - "routing_key": null, - "headers": "{}", - "priority": null, - "expires": null, - "expire_seconds": null, - "one_off": false, - "start_time": null, - "enabled": false, - "last_run_at": "2020-07-14T11:45:26.209Z", - "total_run_count": 307, - "date_changed": "2020-07-14T11:45:41.282Z", - "description": "" - } -}, -{ - "model": "django_celery_beat.periodictask", - "pk": 11, - "fields": { - "name": "Reddit collection task", - "task": "RedditTask", - "interval": 5, - "crontab": null, - "solar": null, - "clocked": null, - "args": "[]", - "kwargs": "{}", - "queue": null, - "exchange": null, - "routing_key": null, - "headers": "{}", - "priority": null, - "expires": null, - "expire_seconds": null, - "one_off": false, - "start_time": null, - "enabled": false, - "last_run_at": null, - "total_run_count": 4, - "date_changed": "2020-07-14T11:45:41.316Z", - "description": "" - } -}, -{ - "model": "core.post", - "pk": 3061, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-21T20:14:50.423Z", - "title": "Star Citizen: Question and Answer Thread", - "body": "

      Welcome to the Star Citizen question and answer thread. Feel free to ask any questions you have related to SC here!

      \n\n\n\n

      Useful Links and Resources:

      \n\n

      Star Citizen Wiki - The biggest and best wiki resource dedicated to Star Citizen

      \n\n

      Star Citizen FAQ - Chances the answer you need is here.

      \n\n

      Discord Help Channel - Often times community members will be here to help you with issues.

      \n\n

      Referral Code Randomizer - Use this when creating a new account to get 5000 extra UEC.

      \n\n

      Download Star Citizen - Get the latest version of Star Citizen here

      \n\n

      Current Game Features - Click here to see what you can currently do in Star Citizen.

      \n\n

      Development Roadmap - The current development status of up and coming Star Citizen features.

      \n\n

      Pledge FAQ - Official FAQ regarding spending money on the game.

      \n
      ", - "author": "UEE_Central_Computer", - "publication_date": "2020-07-20T14:00:10Z", - "url": "https://www.reddit.com/r/starcitizen/comments/huk04t/star_citizen_question_and_answer_thread/", - "read": false, - "rule": 82, - "remote_identifier": "huk04t" - } -}, -{ - "model": "core.post", - "pk": 3062, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-20T19:33:37.019Z", - "title": "Peace and Quiet", - "body": "
      \"Peace
      ", - "author": "SourMemeNZ", - "publication_date": "2020-07-20T14:09:49Z", - "url": "https://www.reddit.com/r/starcitizen/comments/huk4ib/peace_and_quiet/", - "read": true, - "rule": 82, - "remote_identifier": "huk4ib" - } -}, -{ - "model": "core.post", - "pk": 3063, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-21T20:14:50.463Z", - "title": "Y'all are probably sick of em by now but here's my LEGO Mercury Star Runner (MSR).", - "body": "
      \"Y'all
      ", - "author": "osamadabinman", - "publication_date": "2020-07-20T19:53:23Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hupzqa/yall_are_probably_sick_of_em_by_now_but_heres_my/", - "read": true, - "rule": 82, - "remote_identifier": "hupzqa" - } -}, -{ - "model": "core.post", - "pk": 3064, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-21T20:17:12.253Z", - "title": "Damned Space Invaders and their pixel weapons!", - "body": "
      \"Damned
      ", - "author": "Akaradrin", - "publication_date": "2020-07-20T14:26:18Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hukckf/damned_space_invaders_and_their_pixel_weapons/", - "read": true, - "rule": 82, - "remote_identifier": "hukckf" - } -}, -{ - "model": "core.post", - "pk": 3065, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-20T19:32:35.578Z", - "title": "The sky is no longer the limit", - "body": "
      \"The
      ", - "author": "CyberTill", - "publication_date": "2020-07-20T14:11:31Z", - "url": "https://www.reddit.com/r/starcitizen/comments/huk5b8/the_sky_is_no_longer_the_limit/", - "read": false, - "rule": 82, - "remote_identifier": "huk5b8" - } -}, -{ - "model": "core.post", - "pk": 3066, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-21T20:17:23.282Z", - "title": "Terrapin Hover Mode Gameplay [Full Video in Comments]", - "body": "
      ", - "author": "Didactic_Tomato", - "publication_date": "2020-07-20T11:01:13Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hui1gv/terrapin_hover_mode_gameplay_full_video_in/", - "read": true, - "rule": 82, - "remote_identifier": "hui1gv" - } -}, -{ - "model": "core.post", - "pk": 3067, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-21T20:17:44.250Z", - "title": "honestly", - "body": "
      \"honestly\"
      ", - "author": "Beatlead", - "publication_date": "2020-07-20T18:24:07Z", - "url": "https://www.reddit.com/r/starcitizen/comments/huo96t/honestly/", - "read": true, - "rule": 82, - "remote_identifier": "huo96t" - } -}, -{ - "model": "core.post", - "pk": 3068, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-20T19:32:35.584Z", - "title": "As a paranoiac and tired of checking if door was closed, saved to f4 thoses \"security cam\" positions, could be usefull for larger ships :)", - "body": "", - "author": "icwiener__", - "publication_date": "2020-07-20T13:03:33Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hujchz/as_a_paranoiac_and_tired_of_checking_if_door_was/", - "read": false, - "rule": 82, - "remote_identifier": "hujchz" - } -}, -{ - "model": "core.post", - "pk": 3069, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-20T19:33:59.158Z", - "title": "Station Manager: \"You're too fat, we won't let you in, go and fall on Lorville. Thank you for your call!\" Me: \"okay :'(\"", - "body": "
      \"Station
      ", - "author": "Shaman_N_One", - "publication_date": "2020-07-20T11:33:38Z", - "url": "https://www.reddit.com/r/starcitizen/comments/huidlu/station_manager_youre_too_fat_we_wont_let_you_in/", - "read": true, - "rule": 82, - "remote_identifier": "huidlu" - } -}, -{ - "model": "core.post", - "pk": 3070, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-20T19:32:35.588Z", - "title": "[PTU Bug Hunt Request] Packet Loss", - "body": "", - "author": "Rainwalker007", - "publication_date": "2020-07-20T18:38:03Z", - "url": "https://www.reddit.com/r/starcitizen/comments/huoicq/ptu_bug_hunt_request_packet_loss/", - "read": false, - "rule": 82, - "remote_identifier": "huoicq" - } -}, -{ - "model": "core.post", - "pk": 3071, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-21T20:17:52.092Z", - "title": "Anyone able to explain these \"trail frames\"?", - "body": "
      \"Anyone
      ", - "author": "Abnormal_Sloth", - "publication_date": "2020-07-20T17:11:32Z", - "url": "https://www.reddit.com/r/starcitizen/comments/humyeq/anyone_able_to_explain_these_trail_frames/", - "read": true, - "rule": 82, - "remote_identifier": "humyeq" - } -}, -{ - "model": "core.post", - "pk": 3072, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-20T19:32:35.593Z", - "title": "#BringBackBugSmasher - A long forgotten legendary video content", - "body": "", - "author": "MasterBoring", - "publication_date": "2020-07-20T18:05:54Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hunx77/bringbackbugsmasher_a_long_forgotten_legendary/", - "read": false, - "rule": 82, - "remote_identifier": "hunx77" - } -}, -{ - "model": "core.post", - "pk": 3073, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-20T19:33:22.601Z", - "title": "Oracle Helmet [in-game screenshot; downsampled to 4k]", - "body": "
      \"Oracle
      ", - "author": "mr-hasgaha", - "publication_date": "2020-07-20T17:39:34Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hung0b/oracle_helmet_ingame_screenshot_downsampled_to_4k/", - "read": true, - "rule": 82, - "remote_identifier": "hung0b" - } -}, -{ - "model": "core.post", - "pk": 3074, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-20T19:34:42.578Z", - "title": "Testing 3.10 - Gladius in decoupled mode", - "body": "
      ", - "author": "DarkConstant", - "publication_date": "2020-07-19T21:26:52Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hu6f1h/testing_310_gladius_in_decoupled_mode/", - "read": true, - "rule": 82, - "remote_identifier": "hu6f1h" - } -}, -{ - "model": "core.post", - "pk": 3075, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-20T19:34:29.424Z", - "title": "Day 3, I can't stop taking pictures with my Carrack. Send help", - "body": "
      \"Day
      ", - "author": "CyberTill", - "publication_date": "2020-07-20T01:58:15Z", - "url": "https://www.reddit.com/r/starcitizen/comments/huazyy/day_3_i_cant_stop_taking_pictures_with_my_carrack/", - "read": true, - "rule": 82, - "remote_identifier": "huazyy" - } -}, -{ - "model": "core.post", - "pk": 3076, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-20T19:32:35.602Z", - "title": "I used to enjoy flying between the buildings of new babbage, I mean before the NFZ \"improvement\"", - "body": "
      \"I
      ", - "author": "shoeii", - "publication_date": "2020-07-20T16:40:26Z", - "url": "https://www.reddit.com/r/starcitizen/comments/humet2/i_used_to_enjoy_flying_between_the_buildings_of/", - "read": false, - "rule": 82, - "remote_identifier": "humet2" - } -}, -{ - "model": "core.post", - "pk": 3077, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-21T20:18:04.237Z", - "title": "Thank you CIG for updated heightmaps and render distances", - "body": "
      \"Thank
      ", - "author": "u7f76", - "publication_date": "2020-07-19T23:38:22Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hu8pwf/thank_you_cig_for_updated_heightmaps_and_render/", - "read": true, - "rule": 82, - "remote_identifier": "hu8pwf" - } -}, -{ - "model": "core.post", - "pk": 3078, - "fields": { - "created": "2020-07-20T19:32:35.562Z", - "modified": "2020-07-20T19:32:35.607Z", - "title": "This Week in Star Citizen | July 20th 2020", - "body": "", - "author": "ivtiprogamer", - "publication_date": "2020-07-20T19:50:29Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hupxnt/this_week_in_star_citizen_july_20th_2020/", - "read": false, - "rule": 82, - "remote_identifier": "hupxnt" - } -}, -{ - "model": "core.post", - "pk": 3079, - "fields": { - "created": "2020-07-20T19:32:35.563Z", - "modified": "2020-07-20T19:34:36.068Z", - "title": "Bravo CIG lighting team! Noticeable improvements to all around environment lighting in 3.10", - "body": "
      \"Bravo
      ", - "author": "u7f76", - "publication_date": "2020-07-20T00:02:23Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hu94o0/bravo_cig_lighting_team_noticeable_improvements/", - "read": true, - "rule": 82, - "remote_identifier": "hu94o0" - } -}, -{ - "model": "core.post", - "pk": 3080, - "fields": { - "created": "2020-07-20T19:32:35.563Z", - "modified": "2020-07-20T19:32:35.613Z", - "title": "Thick", - "body": "
      \"Thick\"
      ", - "author": "burgerbagel", - "publication_date": "2020-07-20T16:24:38Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hum50f/thick/", - "read": false, - "rule": 82, - "remote_identifier": "hum50f" - } -}, -{ - "model": "core.post", - "pk": 3081, - "fields": { - "created": "2020-07-20T19:32:35.563Z", - "modified": "2020-07-20T19:34:19.763Z", - "title": "Soon\u2122", - "body": "
      \"Soon\u2122\"
      ", - "author": "Mistralette", - "publication_date": "2020-07-20T05:54:09Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hueg01/soon/", - "read": true, - "rule": 82, - "remote_identifier": "hueg01" - } -}, -{ - "model": "core.post", - "pk": 3082, - "fields": { - "created": "2020-07-20T19:32:35.563Z", - "modified": "2020-07-20T19:32:35.618Z", - "title": "On the prowl", - "body": "
      \"On
      ", - "author": "SaraCaterina", - "publication_date": "2020-07-20T16:37:03Z", - "url": "https://www.reddit.com/r/starcitizen/comments/humcmb/on_the_prowl/", - "read": false, - "rule": 82, - "remote_identifier": "humcmb" - } -}, -{ - "model": "core.post", - "pk": 3083, - "fields": { - "created": "2020-07-20T19:32:35.563Z", - "modified": "2020-07-20T19:34:07.272Z", - "title": "The Hills Have Eyes", - "body": "
      \"The
      ", - "author": "FallenLordik", - "publication_date": "2020-07-20T11:19:19Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hui8ao/the_hills_have_eyes/", - "read": true, - "rule": 82, - "remote_identifier": "hui8ao" - } -}, -{ - "model": "core.post", - "pk": 3084, - "fields": { - "created": "2020-07-20T19:32:35.563Z", - "modified": "2020-07-20T19:32:35.623Z", - "title": "Worried about longer loading screens? Hit ~ and do r_displayinfo 3", - "body": "
      \"Worried
      ", - "author": "kristokn", - "publication_date": "2020-07-20T10:09:53Z", - "url": "https://www.reddit.com/r/starcitizen/comments/huhif1/worried_about_longer_loading_screens_hit_and_do_r/", - "read": false, - "rule": 82, - "remote_identifier": "huhif1" - } -}, -{ - "model": "core.post", - "pk": 3085, - "fields": { - "created": "2020-07-20T19:32:35.563Z", - "modified": "2020-07-20T19:32:35.625Z", - "title": "My contribution to the wallpaper contest... click for the full effect (3440x1440)", - "body": "
      \"My
      ", - "author": "Dougie_Juice", - "publication_date": "2020-07-20T20:02:31Z", - "url": "https://www.reddit.com/r/starcitizen/comments/huq655/my_contribution_to_the_wallpaper_contest_click/", - "read": false, - "rule": 82, - "remote_identifier": "huq655" - } -}, -{ - "model": "core.post", - "pk": 3086, - "fields": { - "created": "2020-07-20T19:32:35.563Z", - "modified": "2020-07-20T19:32:35.627Z", - "title": "Star Citizen: The Onion (Parody Project)", - "body": "", - "author": "BroadOne", - "publication_date": "2020-07-20T19:19:20Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hupbkj/star_citizen_the_onion_parody_project/", - "read": false, - "rule": 82, - "remote_identifier": "hupbkj" - } -}, -{ - "model": "core.post", - "pk": 3087, - "fields": { - "created": "2020-07-20T19:32:35.635Z", - "modified": "2020-07-20T19:32:35.637Z", - "title": "perfect day to sunbathe", - "body": "
      ", - "author": "Pedrica1", - "publication_date": "2020-07-20T18:08:17Z", - "url": "https://www.reddit.com/r/aww/comments/hunysb/perfect_day_to_sunbathe/", - "read": false, - "rule": 81, - "remote_identifier": "hunysb" - } -}, -{ - "model": "core.post", - "pk": 3088, - "fields": { - "created": "2020-07-20T19:32:35.635Z", - "modified": "2020-07-20T19:32:35.639Z", - "title": "My dogs face when he sees I'm home", - "body": "
      ", - "author": "NewReddit_WhoDis", - "publication_date": "2020-07-20T16:45:21Z", - "url": "https://www.reddit.com/r/aww/comments/humhxa/my_dogs_face_when_he_sees_im_home/", - "read": false, - "rule": 81, - "remote_identifier": "humhxa" - } -}, -{ - "model": "core.post", - "pk": 3089, - "fields": { - "created": "2020-07-20T19:32:35.635Z", - "modified": "2020-07-20T19:32:35.641Z", - "title": "Cow loves the scritch machine", - "body": "
      ", - "author": "Der_Ist", - "publication_date": "2020-07-20T17:36:16Z", - "url": "https://www.reddit.com/r/aww/comments/hundvo/cow_loves_the_scritch_machine/", - "read": false, - "rule": 81, - "remote_identifier": "hundvo" - } -}, -{ - "model": "core.post", - "pk": 3090, - "fields": { - "created": "2020-07-20T19:32:35.635Z", - "modified": "2020-07-20T19:32:35.643Z", - "title": "Can I sit next to you ?", - "body": "
      ", - "author": "wheezy098", - "publication_date": "2020-07-20T17:55:10Z", - "url": "https://www.reddit.com/r/aww/comments/hunq5h/can_i_sit_next_to_you/", - "read": false, - "rule": 81, - "remote_identifier": "hunq5h" - } -}, -{ - "model": "core.post", - "pk": 3091, - "fields": { - "created": "2020-07-20T19:32:35.635Z", - "modified": "2020-07-20T19:32:35.645Z", - "title": "IS THAT A CUSTOMER? flop flop flop flop .... \" Can I uhh... help you sir?\"", - "body": "
      ", - "author": "MBMV", - "publication_date": "2020-07-20T12:50:40Z", - "url": "https://www.reddit.com/r/aww/comments/huj7g3/is_that_a_customer_flop_flop_flop_flop_can_i_uhh/", - "read": false, - "rule": 81, - "remote_identifier": "huj7g3" - } -}, -{ - "model": "core.post", - "pk": 3092, - "fields": { - "created": "2020-07-20T19:32:35.635Z", - "modified": "2020-07-20T19:32:35.647Z", - "title": "Good Boy turned Disney Princess", - "body": "
      ", - "author": "Sauwercraud", - "publication_date": "2020-07-20T18:40:05Z", - "url": "https://www.reddit.com/r/aww/comments/huojq0/good_boy_turned_disney_princess/", - "read": false, - "rule": 81, - "remote_identifier": "huojq0" - } -}, -{ - "model": "core.post", - "pk": 3093, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.649Z", - "title": "Kitty loop", - "body": "
      ", - "author": "Dlatrex", - "publication_date": "2020-07-20T12:54:02Z", - "url": "https://www.reddit.com/r/aww/comments/huj8s6/kitty_loop/", - "read": false, - "rule": 81, - "remote_identifier": "huj8s6" - } -}, -{ - "model": "core.post", - "pk": 3094, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.652Z", - "title": "if i fits i sits", - "body": "
      ", - "author": "jasontaken", - "publication_date": "2020-07-20T16:38:32Z", - "url": "https://www.reddit.com/r/aww/comments/humdlf/if_i_fits_i_sits/", - "read": false, - "rule": 81, - "remote_identifier": "humdlf" - } -}, -{ - "model": "core.post", - "pk": 3095, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.654Z", - "title": "Isn\u2019t she Adorable !", - "body": "
      \"Isn\u2019t
      ", - "author": "MunchyMac", - "publication_date": "2020-07-20T16:18:05Z", - "url": "https://www.reddit.com/r/aww/comments/hum133/isnt_she_adorable/", - "read": false, - "rule": 81, - "remote_identifier": "hum133" - } -}, -{ - "model": "core.post", - "pk": 3096, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.655Z", - "title": "Thank you mama (\u2283\uff61\u2022\u0301\u203f\u2022\u0300\uff61)\u2283", - "body": "
      ", - "author": "AnoushkaSingh", - "publication_date": "2020-07-20T13:35:51Z", - "url": "https://www.reddit.com/r/aww/comments/hujpxy/thank_you_mama/", - "read": false, - "rule": 81, - "remote_identifier": "hujpxy" - } -}, -{ - "model": "core.post", - "pk": 3097, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.657Z", - "title": "I WANT TO HUG HIM SO BAD!!!", - "body": "
      ", - "author": "BATMAN_5777", - "publication_date": "2020-07-20T18:25:20Z", - "url": "https://www.reddit.com/r/aww/comments/huo9z4/i_want_to_hug_him_so_bad/", - "read": false, - "rule": 81, - "remote_identifier": "huo9z4" - } -}, -{ - "model": "core.post", - "pk": 3098, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.659Z", - "title": "Before and after being called a good boy", - "body": "
      \"Before
      ", - "author": "vladgrinch", - "publication_date": "2020-07-20T10:48:40Z", - "url": "https://www.reddit.com/r/aww/comments/huhwu9/before_and_after_being_called_a_good_boy/", - "read": false, - "rule": 81, - "remote_identifier": "huhwu9" - } -}, -{ - "model": "core.post", - "pk": 3099, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.662Z", - "title": "My fianc\u00e9 has wanted a dog his whole life. This is his college graduation present. Welcome home Maple!", - "body": "
      \"My
      ", - "author": "AlexisaurusRex", - "publication_date": "2020-07-20T17:57:25Z", - "url": "https://www.reddit.com/r/aww/comments/hunrie/my_fianc\u00e9_has_wanted_a_dog_his_whole_life_this_is/", - "read": false, - "rule": 81, - "remote_identifier": "hunrie" - } -}, -{ - "model": "core.post", - "pk": 3100, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.664Z", - "title": "Cute burro.", - "body": "
      \"Cute
      ", - "author": "Craftmine101", - "publication_date": "2020-07-20T13:45:32Z", - "url": "https://www.reddit.com/r/aww/comments/huju40/cute_burro/", - "read": false, - "rule": 81, - "remote_identifier": "huju40" - } -}, -{ - "model": "core.post", - "pk": 3101, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.666Z", - "title": "I've never seen anyone dance better than that turtle.", - "body": "
      ", - "author": "Ashley1023", - "publication_date": "2020-07-20T18:07:30Z", - "url": "https://www.reddit.com/r/aww/comments/hunya8/ive_never_seen_anyone_dance_better_than_that/", - "read": false, - "rule": 81, - "remote_identifier": "hunya8" - } -}, -{ - "model": "core.post", - "pk": 3102, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.669Z", - "title": "Someone\u2019s going to be quite surprised when he realizes all this new stuff isn\u2019t for him!", - "body": "
      \"Someone\u2019s
      ", - "author": "molly590", - "publication_date": "2020-07-20T15:46:21Z", - "url": "https://www.reddit.com/r/aww/comments/hulikg/someones_going_to_be_quite_surprised_when_he/", - "read": false, - "rule": 81, - "remote_identifier": "hulikg" - } -}, -{ - "model": "core.post", - "pk": 3103, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.671Z", - "title": "my aunt asked me to paint her puppy and I think it turned out so cute!!!", - "body": "
      \"my
      ", - "author": "PineappleLightt", - "publication_date": "2020-07-20T16:39:37Z", - "url": "https://www.reddit.com/r/aww/comments/humea0/my_aunt_asked_me_to_paint_her_puppy_and_i_think/", - "read": false, - "rule": 81, - "remote_identifier": "humea0" - } -}, -{ - "model": "core.post", - "pk": 3104, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.673Z", - "title": "Master Assassin", - "body": "
      \"Master
      ", - "author": "LauWalker", - "publication_date": "2020-07-20T18:47:52Z", - "url": "https://www.reddit.com/r/aww/comments/huop8a/master_assassin/", - "read": false, - "rule": 81, - "remote_identifier": "huop8a" - } -}, -{ - "model": "core.post", - "pk": 3105, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.675Z", - "title": "Every time this tank cleaner cleans out the aquarium, this fish swims over to him looking for pets", - "body": "", - "author": "unnaturalorder", - "publication_date": "2020-07-20T05:29:30Z", - "url": "https://www.reddit.com/r/aww/comments/hue3r0/every_time_this_tank_cleaner_cleans_out_the/", - "read": false, - "rule": 81, - "remote_identifier": "hue3r0" - } -}, -{ - "model": "core.post", - "pk": 3106, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.678Z", - "title": "My girlfriend sent me this while I was at work. And here I was thinking the perfect picture of our dog didn't exist", - "body": "", - "author": "Khuma-zi_Eldrama", - "publication_date": "2020-07-20T19:22:48Z", - "url": "https://www.reddit.com/r/aww/comments/hupdz8/my_girlfriend_sent_me_this_while_i_was_at_work/", - "read": false, - "rule": 81, - "remote_identifier": "hupdz8" - } -}, -{ - "model": "core.post", - "pk": 3107, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.680Z", - "title": "My first ever post, everyone meet my new baby girl Kiora! I\u2019m so in love with her\ud83e\udd7a\ud83d\udcab", - "body": "
      \"My
      ", - "author": "Dumpling2463", - "publication_date": "2020-07-20T05:34:29Z", - "url": "https://www.reddit.com/r/aww/comments/hue6dx/my_first_ever_post_everyone_meet_my_new_baby_girl/", - "read": false, - "rule": 81, - "remote_identifier": "hue6dx" - } -}, -{ - "model": "core.post", - "pk": 3108, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.682Z", - "title": "Dog splashing in water", - "body": "", - "author": "TheRikari", - "publication_date": "2020-07-20T15:44:02Z", - "url": "https://www.reddit.com/r/aww/comments/hulh8k/dog_splashing_in_water/", - "read": false, - "rule": 81, - "remote_identifier": "hulh8k" - } -}, -{ - "model": "core.post", - "pk": 3109, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.685Z", - "title": "They say taking breaks is the key to productivity!", - "body": "
      ", - "author": "Thereaper29", - "publication_date": "2020-07-20T05:43:40Z", - "url": "https://www.reddit.com/r/aww/comments/hueawt/they_say_taking_breaks_is_the_key_to_productivity/", - "read": false, - "rule": 81, - "remote_identifier": "hueawt" - } -}, -{ - "model": "core.post", - "pk": 3110, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.687Z", - "title": "I went away for 3 weeks, and now my cat is in love with my husband", - "body": "
      \"I
      ", - "author": "sillykittyish", - "publication_date": "2020-07-20T03:29:11Z", - "url": "https://www.reddit.com/r/aww/comments/hucd7u/i_went_away_for_3_weeks_and_now_my_cat_is_in_love/", - "read": false, - "rule": 81, - "remote_identifier": "hucd7u" - } -}, -{ - "model": "core.post", - "pk": 3111, - "fields": { - "created": "2020-07-20T19:32:35.636Z", - "modified": "2020-07-20T19:32:35.689Z", - "title": "Can you feel the love", - "body": "
      ", - "author": "kettySewrdPic", - "publication_date": "2020-07-20T09:13:32Z", - "url": "https://www.reddit.com/r/aww/comments/hugx1k/can_you_feel_the_love/", - "read": false, - "rule": 81, - "remote_identifier": "hugx1k" - } -}, -{ - "model": "core.post", - "pk": 3112, - "fields": { - "created": "2020-07-20T19:32:35.835Z", - "modified": "2020-07-21T20:14:50.522Z", - "title": "Linux Experiences/Rants or Education/Certifications thread - July 20, 2020", - "body": "

      Welcome to r/linux rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.

      \n\n

      Let us know what's annoying you, whats making you happy, or something that you want to get out to r/linux but didn't make the cut into a full post of it's own.

      \n\n

      For those looking for certifications please use this megathread to ask about how to get certified whether it's for the business world or for your own satisfaction. Be sure to check out r/linuxadmin for more discussion in the SysAdmin world!

      \n\n

      Please keep questions in r/linuxquestions, r/linux4noobs, or the Wednesday automod thread.

      \n
      ", - "author": "AutoModerator", - "publication_date": "2020-07-20T06:12:00Z", - "url": "https://www.reddit.com/r/linux/comments/hueoo0/linux_experiencesrants_or_educationcertifications/", - "read": false, - "rule": 80, - "remote_identifier": "hueoo0" - } -}, -{ - "model": "core.post", - "pk": 3113, - "fields": { - "created": "2020-07-20T19:32:35.836Z", - "modified": "2020-07-21T20:19:49.339Z", - "title": "Unix Family Tree", - "body": "
      \"Unix
      ", - "author": "bauripalash", - "publication_date": "2020-07-20T10:32:15Z", - "url": "https://www.reddit.com/r/linux/comments/huhqrh/unix_family_tree/", - "read": true, - "rule": 80, - "remote_identifier": "huhqrh" - } -}, -{ - "model": "core.post", - "pk": 3114, - "fields": { - "created": "2020-07-20T19:32:35.836Z", - "modified": "2020-07-21T20:14:50.554Z", - "title": "NVIDIA open sourced part of NVAPI SDK to aid 'Windows emulation environments'", - "body": "", - "author": "ignapk", - "publication_date": "2020-07-20T13:17:19Z", - "url": "https://www.reddit.com/r/linux/comments/huji8c/nvidia_open_sourced_part_of_nvapi_sdk_to_aid/", - "read": false, - "rule": 80, - "remote_identifier": "huji8c" - } -}, -{ - "model": "core.post", - "pk": 3115, - "fields": { - "created": "2020-07-20T19:32:35.836Z", - "modified": "2020-07-21T20:14:50.551Z", - "title": "Jellyfin 10.6 released", - "body": "", - "author": "resoluti0n_", - "publication_date": "2020-07-20T16:40:05Z", - "url": "https://www.reddit.com/r/linux/comments/humekr/jellyfin_106_released/", - "read": false, - "rule": 80, - "remote_identifier": "humekr" - } -}, -{ - "model": "core.post", - "pk": 3116, - "fields": { - "created": "2020-07-20T19:32:35.836Z", - "modified": "2020-07-21T20:14:50.583Z", - "title": "[German] Article in major german newspaper about trying Linux and WSL. Literal: \"Why it's beneficial to try Linux now\"", - "body": "", - "author": "noname7890", - "publication_date": "2020-07-19T15:19:27Z", - "url": "https://www.reddit.com/r/linux/comments/hu0d5v/german_article_in_major_german_newspaper_about/", - "read": false, - "rule": 80, - "remote_identifier": "hu0d5v" - } -}, -{ - "model": "core.post", - "pk": 3117, - "fields": { - "created": "2020-07-20T19:32:35.837Z", - "modified": "2020-07-21T20:14:50.574Z", - "title": "Brian Kernighan: UNIX, C, AWK, AMPL, and Go Programming | AI Podcast #109 with Lex Fridman", - "body": "", - "author": "tinyatom", - "publication_date": "2020-07-20T08:48:35Z", - "url": "https://www.reddit.com/r/linux/comments/hugn0w/brian_kernighan_unix_c_awk_ampl_and_go/", - "read": false, - "rule": 80, - "remote_identifier": "hugn0w" - } -}, -{ - "model": "core.post", - "pk": 3118, - "fields": { - "created": "2020-07-20T19:32:35.837Z", - "modified": "2020-07-21T20:14:50.578Z", - "title": "Explaining Computers Host Christopher Barnatt Has Switched To Linux", - "body": "", - "author": "sysrpl", - "publication_date": "2020-07-20T13:00:02Z", - "url": "https://www.reddit.com/r/linux/comments/hujb12/explaining_computers_host_christopher_barnatt_has/", - "read": false, - "rule": 80, - "remote_identifier": "hujb12" - } -}, -{ - "model": "core.post", - "pk": 3119, - "fields": { - "created": "2020-07-20T19:32:35.837Z", - "modified": "2020-07-21T20:14:50.529Z", - "title": "Ireland donates contact tracing app to the Linux foundation.", - "body": "", - "author": "mathiasryan", - "publication_date": "2020-07-20T21:31:43Z", - "url": "https://www.reddit.com/r/linux/comments/hury4e/ireland_donates_contact_tracing_app_to_the_linux/", - "read": false, - "rule": 80, - "remote_identifier": "hury4e" - } -}, -{ - "model": "core.post", - "pk": 3120, - "fields": { - "created": "2020-07-20T19:32:35.842Z", - "modified": "2020-07-21T20:14:50.588Z", - "title": "I implemented a simple terminal-based password manager", - "body": "

      I created a simple, secure, and free password manager written in C: SaltPass. I haven't contributed open source code before, but I think this might be useful to a few people. Especially as an alternative to paid solutions such as LastPass and the likes. Any suggestions/edits/code improvements would be greatly appreciated!

      \n
      ", - "author": "zaid-gg", - "publication_date": "2020-07-20T07:43:03Z", - "url": "https://www.reddit.com/r/linux/comments/hufula/i_implemented_a_simple_terminalbased_password/", - "read": false, - "rule": 80, - "remote_identifier": "hufula" - } -}, -{ - "model": "core.post", - "pk": 3121, - "fields": { - "created": "2020-07-20T19:32:35.843Z", - "modified": "2020-07-21T20:14:50.593Z", - "title": "Performance analysis of multi services on container Docker, LXC, and LXD - Bulletin of Electrical Engineering and Informatics, Adinda Riztia Putri, Rendy Munadi, Ridha Muldina Negara Adaptive Network\u2026", - "body": "", - "author": "bmullan", - "publication_date": "2020-07-20T11:35:59Z", - "url": "https://www.reddit.com/r/linux/comments/huieio/performance_analysis_of_multi_services_on/", - "read": false, - "rule": 80, - "remote_identifier": "huieio" - } -}, -{ - "model": "core.post", - "pk": 3122, - "fields": { - "created": "2020-07-20T19:32:35.844Z", - "modified": "2020-07-21T20:14:50.602Z", - "title": "Create an Internal PKI using OpenSSL and NitroKey HSM", - "body": "", - "author": "PixelPaulaus", - "publication_date": "2020-07-20T06:18:41Z", - "url": "https://www.reddit.com/r/linux/comments/huerpn/create_an_internal_pki_using_openssl_and_nitrokey/", - "read": false, - "rule": 80, - "remote_identifier": "huerpn" - } -}, -{ - "model": "core.post", - "pk": 3123, - "fields": { - "created": "2020-07-20T19:32:35.844Z", - "modified": "2020-07-20T19:32:35.883Z", - "title": "vopono - run applications via VPNs with temporary network namespaces", - "body": "", - "author": "nivenkos", - "publication_date": "2020-07-19T20:02:57Z", - "url": "https://www.reddit.com/r/linux/comments/hu4vge/vopono_run_applications_via_vpns_with_temporary/", - "read": false, - "rule": 80, - "remote_identifier": "hu4vge" - } -}, -{ - "model": "core.post", - "pk": 3124, - "fields": { - "created": "2020-07-20T19:32:35.849Z", - "modified": "2020-07-20T19:32:35.886Z", - "title": "Double (triple, quadruple...) internet speed with openvpn tap channel bonding to a linux VPS", - "body": "

      I have been working a couple of days on my latest video about channel bonding - the video is heavily inspired be this article on Serverfault. In essence, I have been searching for a while on how to bond multiple VPN channels together in order to increase internet speed - there does not seem to be a lot of information around - mainly articles on forums and reddit state that it should be possible but a detailed guide is hard to find. I am using two Ubuntu machines in order to build the connection - one local and one VPS. The bash scripts I use in my video in order to achieve tap channel bonding are available on my github repository. I am currently working on a second video in order to walk through and explain the scripts in depth. Enjoy!

      \n\n

      (EDIT) - the question has come up in the discussions below if this is really packet load balancing or rather balancing links only - please see my comment further down - I can confirm that this DOES packet balancing so it does work as described.

      \n
      ", - "author": "onemarcfifty", - "publication_date": "2020-07-19T20:41:40Z", - "url": "https://www.reddit.com/r/linux/comments/hu5l4f/double_triple_quadruple_internet_speed_with/", - "read": false, - "rule": 80, - "remote_identifier": "hu5l4f" - } -}, -{ - "model": "core.post", - "pk": 3125, - "fields": { - "created": "2020-07-20T19:32:35.849Z", - "modified": "2020-07-20T19:32:35.888Z", - "title": "OpenRGB - Open source RGB lighting control that doesn't depend on manufacturer software, supports Linux", - "body": "", - "author": "pr0_c0d3", - "publication_date": "2020-07-18T16:52:48Z", - "url": "https://www.reddit.com/r/linux/comments/hthuli/openrgb_open_source_rgb_lighting_control_that/", - "read": false, - "rule": 80, - "remote_identifier": "hthuli" - } -}, -{ - "model": "core.post", - "pk": 3126, - "fields": { - "created": "2020-07-20T19:32:35.849Z", - "modified": "2020-07-20T19:32:35.890Z", - "title": "Make this any sense? Automatic CPU Speed & Power Optimizer", - "body": "", - "author": "spite77", - "publication_date": "2020-07-20T11:53:35Z", - "url": "https://www.reddit.com/r/linux/comments/huikxz/make_this_any_sense_automatic_cpu_speed_power/", - "read": false, - "rule": 80, - "remote_identifier": "huikxz" - } -}, -{ - "model": "core.post", - "pk": 3127, - "fields": { - "created": "2020-07-20T19:32:35.849Z", - "modified": "2020-07-20T19:32:35.891Z", - "title": "Let\u2019s not be pedantic about \u201cOpen Source\u201d", - "body": "", - "author": "speckz", - "publication_date": "2020-07-20T16:46:43Z", - "url": "https://www.reddit.com/r/linux/comments/humirw/lets_not_be_pedantic_about_open_source/", - "read": false, - "rule": 80, - "remote_identifier": "humirw" - } -}, -{ - "model": "core.post", - "pk": 3128, - "fields": { - "created": "2020-07-20T19:32:35.849Z", - "modified": "2020-07-20T19:32:35.893Z", - "title": "Experiences with running Linux Lite", - "body": "", - "author": "daemonpenguin", - "publication_date": "2020-07-20T02:43:49Z", - "url": "https://www.reddit.com/r/linux/comments/hubonw/experiences_with_running_linux_lite/", - "read": false, - "rule": 80, - "remote_identifier": "hubonw" - } -}, -{ - "model": "core.post", - "pk": 3129, - "fields": { - "created": "2020-07-20T19:32:35.849Z", - "modified": "2020-07-20T19:32:35.895Z", - "title": "Tried gnome on arch, surprised how lean it is (used flameshot so it used about 72mb more) closing at 600 megs) on fedora and pop i had gnome eating up 1.3gigs at boot up.", - "body": "
      \"Tried
      ", - "author": "V1n0dKr1shna", - "publication_date": "2020-07-18T13:54:55Z", - "url": "https://www.reddit.com/r/linux/comments/htfeph/tried_gnome_on_arch_surprised_how_lean_it_is_used/", - "read": false, - "rule": 80, - "remote_identifier": "htfeph" - } -}, -{ - "model": "core.post", - "pk": 3130, - "fields": { - "created": "2020-07-20T19:32:35.849Z", - "modified": "2020-07-20T19:32:35.897Z", - "title": "The Free Software Foundation is holding a Fundraiser, help them reach 200 members", - "body": "", - "author": "Neet-Feet", - "publication_date": "2020-07-18T17:55:30Z", - "url": "https://www.reddit.com/r/linux/comments/htiuyi/the_free_software_foundation_is_holding_a/", - "read": false, - "rule": 80, - "remote_identifier": "htiuyi" - } -}, -{ - "model": "core.post", - "pk": 3131, - "fields": { - "created": "2020-07-20T19:32:35.853Z", - "modified": "2020-07-20T19:32:35.899Z", - "title": "Why is the mindset around Arch so negative?", - "body": "

      I love the Linux community as a whole. You can find some of the most creative and imaginative people within most Linux communities. On a whole, Linux users are some of the most helpful and informative people you can encounter. Truly the type to think outside the box and learn new things. It can be very inspirational.

      \n\n

      If I jumped onto Ubuntu, Fedora, or openSUSE's community I can have a free flowing conversation about Linux, their distribution, and getting help or giving help is so free-flowing and easy. The communities are eager to welcome new people and appreciate folks who contribute.

      \n\n

      Then you have Arch. I love the OS but dislike the mindset. Asking for help is meat with resistance, giving help can also be punishable, and god forbid you try to have a discussion. But it's not just their core community either. For example, I just discovered Endeavour OS which is built around Arch and after 11 post I'm told to come back in 8 hours. Their subReddit here on Reddit, you have to ask to even make 1 post. There of course is also Manjaro Linux and they too have this gatekeeper mindset, the same can be said for ArcoLinux.

      \n\n

      What is it about Arch that makes everyone want to be either a control freak or a gatekeeper?

      \n\n

      I do not see this within the Ubuntu or Fedora or openSUSE communities. As I said, their mindset seems eager and willing to unite and work as a community. Am I the only how has noticed this?

      \n
      ", - "author": "Linux-Is-Best", - "publication_date": "2020-07-18T23:28:12Z", - "url": "https://www.reddit.com/r/linux/comments/htojwk/why_is_the_mindset_around_arch_so_negative/", - "read": false, - "rule": 80, - "remote_identifier": "htojwk" - } -}, -{ - "model": "core.post", - "pk": 3132, - "fields": { - "created": "2020-07-20T19:32:35.853Z", - "modified": "2020-07-20T19:32:35.901Z", - "title": "Using the nstat network statistics command in Linux", - "body": "", - "author": "cronos426", - "publication_date": "2020-07-19T17:55:55Z", - "url": "https://www.reddit.com/r/linux/comments/hu2q6v/using_the_nstat_network_statistics_command_in/", - "read": false, - "rule": 80, - "remote_identifier": "hu2q6v" - } -}, -{ - "model": "core.post", - "pk": 3133, - "fields": { - "created": "2020-07-20T19:32:35.853Z", - "modified": "2020-07-20T19:32:35.903Z", - "title": "Contributing via GitLab Merge Requests", - "body": "", - "author": "ChristophCullmann", - "publication_date": "2020-07-18T20:01:26Z", - "url": "https://www.reddit.com/r/linux/comments/htl05p/contributing_via_gitlab_merge_requests/", - "read": false, - "rule": 80, - "remote_identifier": "htl05p" - } -}, -{ - "model": "core.post", - "pk": 3134, - "fields": { - "created": "2020-07-20T19:32:35.853Z", - "modified": "2020-07-20T19:32:35.905Z", - "title": "OpenMandriva: combines WINE64 and 32 into one package capable of running both binaries, i686 architecture was considered as deprecated. Work is underway on a new Rolling release", - "body": "", - "author": "DamonsLinux", - "publication_date": "2020-07-18T15:02:35Z", - "url": "https://www.reddit.com/r/linux/comments/htg9dj/openmandriva_combines_wine64_and_32_into_one/", - "read": false, - "rule": 80, - "remote_identifier": "htg9dj" - } -}, -{ - "model": "core.post", - "pk": 3135, - "fields": { - "created": "2020-07-20T19:32:35.853Z", - "modified": "2020-07-20T19:32:35.906Z", - "title": "OpenRCT2 Player Survey 2020 - Previous survey shows almost 25% players are linux, please help represent linux in the most recent survey", - "body": "", - "author": "christophski", - "publication_date": "2020-07-18T11:39:06Z", - "url": "https://www.reddit.com/r/linux/comments/htdzuh/openrct2_player_survey_2020_previous_survey_shows/", - "read": false, - "rule": 80, - "remote_identifier": "htdzuh" - } -}, -{ - "model": "core.post", - "pk": 3136, - "fields": { - "created": "2020-07-20T19:32:35.853Z", - "modified": "2020-07-20T19:32:35.908Z", - "title": "This week in KDE: Get New Stuff fixes and more", - "body": "", - "author": "kyentei", - "publication_date": "2020-07-18T10:03:46Z", - "url": "https://www.reddit.com/r/linux/comments/htd1an/this_week_in_kde_get_new_stuff_fixes_and_more/", - "read": false, - "rule": 80, - "remote_identifier": "htd1an" - } -}, -{ - "model": "core.post", - "pk": 3137, - "fields": { - "created": "2020-07-20T19:32:35.857Z", - "modified": "2020-07-20T19:32:35.910Z", - "title": "Blender Runs on Linux Pinephone", - "body": "

      I managed to get the desktop version of Blender on the Pinephone, and it works really well except for a few bugs.

      \n\n

      See my post on r/blender:

      \n\n

      https://www.reddit.com/r/blender/comments/hsxv27/i_installed_blender_on_a_phone/

      \n\n

      and r/PINE64official:

      \n\n

      https://www.reddit.com/r/PINE64official/comments/hsxc33/blender_on_pine_phone_almost_usable/

      \n\n

      I've tried other desktop programs like Xournal and PPSSPP, their UIs also work well, I'd be able to do even more if OpenGL 3 was working.

      \n
      ", - "author": "InfiniteHawk", - "publication_date": "2020-07-17T22:35:14Z", - "url": "https://www.reddit.com/r/linux/comments/ht3d4k/blender_runs_on_linux_pinephone/", - "read": false, - "rule": 80, - "remote_identifier": "ht3d4k" - } -}, -{ - "model": "core.post", - "pk": 3138, - "fields": { - "created": "2020-07-21T20:14:50.415Z", - "modified": "2020-07-21T20:18:21.616Z", - "title": "Hrmmm They Need to Fix Throttle Animations in the Sabre", - "body": "
      ", - "author": "TheBootRanger", - "publication_date": "2020-07-21T13:26:01Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv5omc/hrmmm_they_need_to_fix_throttle_animations_in_the/", - "read": true, - "rule": 82, - "remote_identifier": "hv5omc" - } -}, -{ - "model": "core.post", - "pk": 3139, - "fields": { - "created": "2020-07-21T20:14:50.415Z", - "modified": "2020-07-21T20:18:49.999Z", - "title": "My first 3.10 landing could have gone better...", - "body": "
      ", - "author": "KnLfey", - "publication_date": "2020-07-21T16:04:50Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv7w85/my_first_310_landing_could_have_gone_better/", - "read": true, - "rule": 82, - "remote_identifier": "hv7w85" - } -}, -{ - "model": "core.post", - "pk": 3140, - "fields": { - "created": "2020-07-21T20:14:50.415Z", - "modified": "2020-07-21T20:14:50.439Z", - "title": "How about the Christmas in 3 more years?", - "body": "
      \"How
      ", - "author": "SpleanEater", - "publication_date": "2020-07-21T17:49:22Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv9qy8/how_about_the_christmas_in_3_more_years/", - "read": false, - "rule": 82, - "remote_identifier": "hv9qy8" - } -}, -{ - "model": "core.post", - "pk": 3141, - "fields": { - "created": "2020-07-21T20:14:50.415Z", - "modified": "2020-07-21T20:18:33.532Z", - "title": "Long time Elite Dangerous player. New to star citizen i think im doing great", - "body": "", - "author": "Filblo5", - "publication_date": "2020-07-21T15:33:49Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv7elb/long_time_elite_dangerous_player_new_to_star/", - "read": true, - "rule": 82, - "remote_identifier": "hv7elb" - } -}, -{ - "model": "core.post", - "pk": 3142, - "fields": { - "created": "2020-07-21T20:14:50.416Z", - "modified": "2020-07-21T20:14:50.443Z", - "title": "And we stand by it.", - "body": "
      \"And
      ", - "author": "CyberTill", - "publication_date": "2020-07-21T18:57:48Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hvb3wm/and_we_stand_by_it/", - "read": false, - "rule": 82, - "remote_identifier": "hvb3wm" - } -}, -{ - "model": "core.post", - "pk": 3143, - "fields": { - "created": "2020-07-21T20:14:50.416Z", - "modified": "2020-07-21T20:14:50.446Z", - "title": "Nomad", - "body": "
      \"Nomad\"
      ", - "author": "ibracitizen", - "publication_date": "2020-07-21T19:52:24Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hvc5h3/nomad/", - "read": false, - "rule": 82, - "remote_identifier": "hvc5h3" - } -}, -{ - "model": "core.post", - "pk": 3144, - "fields": { - "created": "2020-07-21T20:14:50.416Z", - "modified": "2020-07-21T20:14:50.449Z", - "title": "Probably the best screen cap i've ever caught on a whim. 3.5 Arc Corp release. Also a confession: I never pledged. Got a ship with my GPU. I intend to pay my dues.", - "body": "
      \"Probably
      ", - "author": "ScionoicS", - "publication_date": "2020-07-21T20:23:01Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hvcqzf/probably_the_best_screen_cap_ive_ever_caught_on_a/", - "read": false, - "rule": 82, - "remote_identifier": "hvcqzf" - } -}, -{ - "model": "core.post", - "pk": 3145, - "fields": { - "created": "2020-07-21T20:14:50.416Z", - "modified": "2020-07-21T20:14:50.451Z", - "title": "Play to escape the depressing job hunt where I need 10 years experience for a entry level job to find this, only been playing for 1 and a half years :(", - "body": "
      \"Play
      ", - "author": "Albert-III-", - "publication_date": "2020-07-21T12:23:45Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv4z08/play_to_escape_the_depressing_job_hunt_where_i/", - "read": false, - "rule": 82, - "remote_identifier": "hv4z08" - } -}, -{ - "model": "core.post", - "pk": 3146, - "fields": { - "created": "2020-07-21T20:14:50.416Z", - "modified": "2020-07-21T20:19:00.691Z", - "title": "The void beckons.", - "body": "
      ", - "author": "HisNameWasHis", - "publication_date": "2020-07-21T14:40:51Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv6nij/the_void_beckons/", - "read": true, - "rule": 82, - "remote_identifier": "hv6nij" - } -}, -{ - "model": "core.post", - "pk": 3147, - "fields": { - "created": "2020-07-21T20:14:50.416Z", - "modified": "2020-07-21T20:19:05.881Z", - "title": "I made a SC-like Photobash with Soldiers", - "body": "
      \"I
      ", - "author": "IsaacPolar", - "publication_date": "2020-07-21T17:13:39Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv92ri/i_made_a_sclike_photobash_with_soldiers/", - "read": true, - "rule": 82, - "remote_identifier": "hv92ri" - } -}, -{ - "model": "core.post", - "pk": 3148, - "fields": { - "created": "2020-07-21T20:14:50.416Z", - "modified": "2020-07-21T20:19:41.227Z", - "title": "Ocean Shader Improvements", - "body": "
      \"Ocean
      ", - "author": "shoeii", - "publication_date": "2020-07-21T18:41:51Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hvasds/ocean_shader_improvements/", - "read": true, - "rule": 82, - "remote_identifier": "hvasds" - } -}, -{ - "model": "core.post", - "pk": 3149, - "fields": { - "created": "2020-07-21T20:14:50.420Z", - "modified": "2020-07-21T20:14:50.459Z", - "title": "As much shit as Star Citizen (rightfully) gets it still does one thing better than any other 'game' I've played", - "body": "

      It invokes a real sense of scale, on multiple levels.

      \n\n

      One could argue that's one of the most important feelings you'd want to capture in any game set in space, but of course it's mostly meaningless if there aren't enough gameplay loops and systems in place to work in tandem with and make the space that's been created interesting, and that's where SC is currently a failure.

      \n\n

      Even so, I think being able to create that sense of smallness isn't insignificant.

      \n\n

      You as a pilot are dwarfed by your ship which is itself dwarfed by a larger ship which is itself dwarfed by another, even more massive one which is dwarfed by the space station or hub you're at which is dwarfed by a crater on a moon which is dwarfed by the moon itself which is dwarfed by the planet it orbits which is dwarfed by the sheer vastness of space in between all of those things and that they are, despite the distance, still connected.

      \n\n

      Getting lost in Lorville (even if it is mostly linear) and knowing it's only a small part of the playable space is a really neat feeling - looking out from the windows of the train up into the sky and knowing you can go there and beyond really makes you feel like there is a whole world (and more) waiting to be explored.

      \n\n

      I think this is a direct result of having legs and not being locked into the cockpit of your ship - I've played more Elite: Dangerous than Star Citizen and it accomplishes a similar sense of scale but, at least not as far as I've felt, never to the same degree - because you're locked in your ship you never really get this same sense of being small or insignificant even though you are dwarfed in similar ways by planets/asteroids/other ships - will be interesting to see how their implementation of 'space legs' in the upcoming expansion changes this.

      \n\n

      My favourite thing to do in Star Citizen (because there isn't a whole lot) is to just find some pocket of space far away from anything else and just walk around my ship, feeling truly alone and insignificant, gazing out at the void that stretches infinitely all around - something about that is super comfy.

      \n\n

      I can't think of many other game that accomplish a similar level of scale though I'm sure they exist.

      \n\n

      I've been playing an indie game called Empyrion - Galactic Survival and it actually is sort of similar to SC in this regard but it's nowhere near as polished or smooth - transitions from atmosphere to space are not truly seamless and planets themselves are kind of stitched together, but it still manages to invoke that same kind of awe at the scale of things when you dock a small vessel to a capital vessel, for example - definitely worth checking out if you like sci-fi/space games, which you must if you're here, but just be prepared for the jank.

      \n
      ", - "author": "thegreatself", - "publication_date": "2020-07-21T20:30:15Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hvcw38/as_much_shit_as_star_citizen_rightfully_gets_it/", - "read": false, - "rule": 82, - "remote_identifier": "hvcw38" - } -}, -{ - "model": "core.post", - "pk": 3150, - "fields": { - "created": "2020-07-21T20:14:50.420Z", - "modified": "2020-07-21T20:14:50.462Z", - "title": "You waiting for patch 3.10 to go live while watching tons of videos about the new flight model features. Be patient, 3.11 and 3.12 will be even better.", - "body": "
      \"You
      ", - "author": "jsabater76", - "publication_date": "2020-07-21T09:39:27Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv372v/you_waiting_for_patch_310_to_go_live_while/", - "read": false, - "rule": 82, - "remote_identifier": "hv372v" - } -}, -{ - "model": "core.post", - "pk": 3151, - "fields": { - "created": "2020-07-21T20:14:50.420Z", - "modified": "2020-07-21T20:14:50.466Z", - "title": "CIG, can we please fix these \"black hole\" doors(when they are closed) on ships please.", - "body": "
      \"CIG,
      ", - "author": "AbnormallyBendPenis", - "publication_date": "2020-07-21T13:40:14Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv5uzj/cig_can_we_please_fix_these_black_hole_doorswhen/", - "read": false, - "rule": 82, - "remote_identifier": "hv5uzj" - } -}, -{ - "model": "core.post", - "pk": 3152, - "fields": { - "created": "2020-07-21T20:14:50.420Z", - "modified": "2020-07-21T20:14:50.468Z", - "title": "Anvil Super Hornet over Cellin", - "body": "
      \"Anvil
      ", - "author": "SaraCaterina", - "publication_date": "2020-07-21T20:33:58Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hvcyq6/anvil_super_hornet_over_cellin/", - "read": false, - "rule": 82, - "remote_identifier": "hvcyq6" - } -}, -{ - "model": "core.post", - "pk": 3153, - "fields": { - "created": "2020-07-21T20:14:50.420Z", - "modified": "2020-07-21T20:14:50.471Z", - "title": "3.10 Combat Changes", - "body": "", - "author": "STLYoungblood", - "publication_date": "2020-07-21T16:37:44Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv8fr7/310_combat_changes/", - "read": false, - "rule": 82, - "remote_identifier": "hv8fr7" - } -}, -{ - "model": "core.post", - "pk": 3154, - "fields": { - "created": "2020-07-21T20:14:50.420Z", - "modified": "2020-07-21T20:14:50.472Z", - "title": "Hey CIG how about that S42 Vi.... Oh...", - "body": "
      \"Hey
      ", - "author": "SiEDeN", - "publication_date": "2020-07-21T21:37:16Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hve6am/hey_cig_how_about_that_s42_vi_oh/", - "read": false, - "rule": 82, - "remote_identifier": "hve6am" - } -}, -{ - "model": "core.post", - "pk": 3155, - "fields": { - "created": "2020-07-21T20:14:50.422Z", - "modified": "2020-07-21T20:14:50.475Z", - "title": "3.10 M PTU Eclipse improvements", - "body": "

      If this goes live, CIG had addressed 2 of my Eclipse critics.

      \n\n

      Not because of my videos of course, CIG doesn't know I exist.

      \n\n

       

      \n\n

      a. Eclipse has armor stealth in 3.10, see my table:\nhttps://docs.google.com/spreadsheets/d/1OJXg7MQsG_IVTPsmlmZYaxEPK4n4iqnhQx4oigIlJHg/edit#gid=343807746

      \n\n

       

      \n\n

      b. Eclipse can fire her size 9 torpedoes way quicker now, see my video with a side by side comparison of the max firing speed in 3.9 and 3.10:\nhttps://youtu.be/GFTF1Qt7T3o?t=207

      \n
      ", - "author": "Camural", - "publication_date": "2020-07-21T18:15:50Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hva9lc/310_m_ptu_eclipse_improvements/", - "read": false, - "rule": 82, - "remote_identifier": "hva9lc" - } -}, -{ - "model": "core.post", - "pk": 3156, - "fields": { - "created": "2020-07-21T20:14:50.422Z", - "modified": "2020-07-21T20:14:50.477Z", - "title": "Hark! The Drake Herald Sings", - "body": "
      \"Hark!
      ", - "author": "CyrexStorm", - "publication_date": "2020-07-21T16:19:31Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv84kk/hark_the_drake_herald_sings/", - "read": false, - "rule": 82, - "remote_identifier": "hv84kk" - } -}, -{ - "model": "core.post", - "pk": 3157, - "fields": { - "created": "2020-07-21T20:14:50.422Z", - "modified": "2020-07-21T20:14:50.479Z", - "title": "The new flight stick in the Prowler", - "body": "
      \"The
      ", - "author": "Potato_Nades", - "publication_date": "2020-07-21T16:22:22Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv86c2/the_new_flight_stick_in_the_prowler/", - "read": false, - "rule": 82, - "remote_identifier": "hv86c2" - } -}, -{ - "model": "core.post", - "pk": 3158, - "fields": { - "created": "2020-07-21T20:14:50.422Z", - "modified": "2020-07-21T20:14:50.481Z", - "title": "Norwegian VAT charged from August 1st", - "body": "
      \"Norwegian
      ", - "author": "norgeek", - "publication_date": "2020-07-21T10:30:57Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv3r3l/norwegian_vat_charged_from_august_1st/", - "read": false, - "rule": 82, - "remote_identifier": "hv3r3l" - } -}, -{ - "model": "core.post", - "pk": 3159, - "fields": { - "created": "2020-07-21T20:14:50.423Z", - "modified": "2020-07-21T20:14:50.484Z", - "title": "With Pyro (currently WIP), Nyx (partially done), Odin (S42), currently on the way, what is everyone\u2019s thoughts on Terra possibly being next on the list of star systems to be added into the PU within \u2026", - "body": "
      \"With
      ", - "author": "realCLTotaku", - "publication_date": "2020-07-21T13:27:09Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hv5p41/with_pyro_currently_wip_nyx_partially_done_odin/", - "read": false, - "rule": 82, - "remote_identifier": "hv5p41" - } -}, -{ - "model": "core.post", - "pk": 3160, - "fields": { - "created": "2020-07-21T20:14:50.423Z", - "modified": "2020-07-21T20:14:50.486Z", - "title": "Testing out the new electron rifle", - "body": "
      ", - "author": "joshbaker2112", - "publication_date": "2020-07-21T02:56:19Z", - "url": "https://www.reddit.com/r/starcitizen/comments/huxr6d/testing_out_the_new_electron_rifle/", - "read": false, - "rule": 82, - "remote_identifier": "huxr6d" - } -}, -{ - "model": "core.post", - "pk": 3161, - "fields": { - "created": "2020-07-21T20:14:50.423Z", - "modified": "2020-07-21T20:14:50.487Z", - "title": "Imperial Geographic's Lovecraftian magazine special is here. \ud83d\udc19 Find the link in the comments!", - "body": "
      \"Imperial
      ", - "author": "Good_Punk2", - "publication_date": "2020-07-21T18:21:38Z", - "url": "https://www.reddit.com/r/starcitizen/comments/hvadrh/imperial_geographics_lovecraftian_magazine/", - "read": false, - "rule": 82, - "remote_identifier": "hvadrh" - } -}, -{ - "model": "core.post", - "pk": 3162, - "fields": { - "created": "2020-07-21T20:14:50.497Z", - "modified": "2020-07-21T20:14:50.525Z", - "title": "Linux Distributions Timeline", - "body": "
      \"Linux
      ", - "author": "bauripalash", - "publication_date": "2020-07-21T06:07:59Z", - "url": "https://www.reddit.com/r/linux/comments/hv0ktn/linux_distributions_timeline/", - "read": false, - "rule": 80, - "remote_identifier": "hv0ktn" - } -}, -{ - "model": "core.post", - "pk": 3163, - "fields": { - "created": "2020-07-21T20:14:50.497Z", - "modified": "2020-07-21T20:14:50.527Z", - "title": "Fedora: Proposal to replace default wined3d backend with DXVK", - "body": "", - "author": "friskfrugt", - "publication_date": "2020-07-21T19:42:49Z", - "url": "https://www.reddit.com/r/linux/comments/hvbyyr/fedora_proposal_to_replace_default_wined3d/", - "read": false, - "rule": 80, - "remote_identifier": "hvbyyr" - } -}, -{ - "model": "core.post", - "pk": 3164, - "fields": { - "created": "2020-07-21T20:14:50.497Z", - "modified": "2020-07-21T20:14:50.531Z", - "title": "Update on marketing and communication plans for the LibreOffice 7.x series", - "body": "", - "author": "TheQuantumZero", - "publication_date": "2020-07-21T09:59:23Z", - "url": "https://www.reddit.com/r/linux/comments/hv3erm/update_on_marketing_and_communication_plans_for/", - "read": false, - "rule": 80, - "remote_identifier": "hv3erm" - } -}, -{ - "model": "core.post", - "pk": 3165, - "fields": { - "created": "2020-07-21T20:14:50.497Z", - "modified": "2020-07-21T20:14:50.533Z", - "title": "FOSS job opening: LibreOffice Development Mentor at The Document Foundation", - "body": "", - "author": "themikeosguy", - "publication_date": "2020-07-21T14:26:36Z", - "url": "https://www.reddit.com/r/linux/comments/hv6gfw/foss_job_opening_libreoffice_development_mentor/", - "read": false, - "rule": 80, - "remote_identifier": "hv6gfw" - } -}, -{ - "model": "core.post", - "pk": 3166, - "fields": { - "created": "2020-07-21T20:14:50.503Z", - "modified": "2020-07-21T20:14:50.536Z", - "title": "gomd - quickly display formatted markdown files with code highlight in your browser", - "body": "

      Hi all!

      \n\n

      I wanted to share a project I've been working on recently. I think it reached a stage where it's pretty usable and should work out of the box. gomd sets up a HTTP server and serves a directory in your browser so you can quickly view your markdown files. It comes with some neat features like:

      \n\n
        \n
      • Monitoring files - it will monitor files for changes and reload them whenever needed
      • \n
      • Hot reloading - whenever the file you are currently viewing changes, the tab in your browser will reload automatically.
      • \n
      • Code Highlight - All blocks of code in most common languages will be color highlighted.
      • \n
      • Themes - choose from multiple themes like: solarized, monokai, github, dracula...
      • \n
      \n\n

      Link: gomd

      \n\n

      For now its only available from AUR or built from source.

      \n\n

      \n\n

      Any tips or feedback will be greatly appreciated :)

      \n
      ", - "author": "wwojtekk", - "publication_date": "2020-07-21T20:07:31Z", - "url": "https://www.reddit.com/r/linux/comments/hvcg44/gomd_quickly_display_formatted_markdown_files/", - "read": false, - "rule": 80, - "remote_identifier": "hvcg44" - } -}, -{ - "model": "core.post", - "pk": 3167, - "fields": { - "created": "2020-07-21T20:14:50.503Z", - "modified": "2020-07-21T20:14:50.543Z", - "title": "They're not otherwise wrong, but it didn't become a real Internet standard until 2017.", - "body": "
      \"They're
      ", - "author": "foodown", - "publication_date": "2020-07-21T21:39:09Z", - "url": "https://www.reddit.com/r/linux/comments/hve7l5/theyre_not_otherwise_wrong_but_it_didnt_become_a/", - "read": false, - "rule": 80, - "remote_identifier": "hve7l5" - } -}, -{ - "model": "core.post", - "pk": 3168, - "fields": { - "created": "2020-07-21T20:14:50.503Z", - "modified": "2020-07-21T20:14:50.545Z", - "title": "Drawing - an alternative to Paint for Linux (gtk3, support HiDPI)", - "body": "", - "author": "dontdieych", - "publication_date": "2020-07-21T02:37:22Z", - "url": "https://www.reddit.com/r/linux/comments/huxgsg/drawing_an_alternative_to_paint_for_linux_gtk3/", - "read": false, - "rule": 80, - "remote_identifier": "huxgsg" - } -}, -{ - "model": "core.post", - "pk": 3169, - "fields": { - "created": "2020-07-21T20:14:50.509Z", - "modified": "2020-07-21T20:14:50.547Z", - "title": "Observations on a Linux issue with 3.5mm earphones with a mic", - "body": "

      Alright hello. I have come from r/SolusProject and I made a post there to do with headphone issues. I suggest you read through the post and comments to get a better understanding before reading this https://www.reddit.com/r/SolusProject/comments/hsql4d/frustrating_headphone_issues/. I had posted to do with it again, but it got taken down for duplication (when it wasn't duplication). This post is more of my observations from experimenting and such. There are distros I haven't tried but I tried a wide range of distros like manjaro, ubuntu based ones and all solus flavors, and I was looking more for how well they worked out of the box, rather than with fiddling around with pulse, hdajack etc which I know will work eventually. If you stumbled across this from searching about the same issue I have (or similar) or are confused to what this is about, I suggest you look at my previous post also.

      \n\n

      So anyways, I've tried the past few days mounting isos to usb drives and trying live os and installing various distros to see about the headphone issue. And my conclusion is that this issue affects the linux kernel in some way across the board. I don't really understand why completely but I have some kind of idea.

      \n\n

      From installing fresh distros, I noticed that the earphones (they are 3.5mm earphones + mic) get recognised as a microphone and not as a speaker system of some kind. Every single time I had a look at the sound settings and in pulse, they came up as plugged microphone, with the internal speakers being the only output device every single time. It's really odd seeing as how ubuntu 14.04 and xubuntu etc from years past worked flawlessly with the earphones, even manjaro a while ago on my older craptop worked fine. I don't really understand why it doesn't work on my device now.

      \n\n

      I'll leave my specs at the bottom of this post but what I think is is there's something the manufacturer did, or something like the cpu causes issue with linux. The manufacturer of my laptop is Lenovo, and the cpu/igpu is from AMD. A warning sign is that when installing a linux distro, it doesn't bring up the dual boot menu at startup like it should. Instead it completely hides the fact it exists until I use something like easyuefi to add an option for that distro, how it works is you specify the boot partition, whether it's linux or windows and the loader conf file for the distro. All of this hassle everytime doesn't appear on my craptop, because the dual boot menu appears flawlessly without issue. May be because it uses an Intel cpu/igpu unlike my newer laptop but it's hard to say.

      \n\n

      Also, it seems like the devices that appear in a given distro when looking at alsa, is hd generic devices but by reloading alsa or any command that shows the full name of the device, it says it's Intel. I don't know if that would be an issue, maybe amd use intel sound drivers or something. It's odd nonetheless.

      \n\n

      This issue has been boggling my mind for obvious reasons, with half-rhetorical questions like does linux not support the earphones anymore, whether out of accident from an overlooked bug in an update or intentionally phasing out? Is any of this AMD or Lenovo's fault? Even with proper headphones or something, will they fail? I don't think anyone here really knows, hell I'd bet an extreme that no one really understands why in the linux community. I kinda rambled in this post with stuff that should've been said in the last post/thread, but I'm saying it now.

      \n\n

      Thanks for contributing thus far to this discussion in figuring this out.

      \n\n

      Specs: AMD Ryzen 5 3500U Mobile CPU (2.2 - 3.7ghz quad core)

      \n\n

      Radeon Vega 8 Integrated GPU, 8GB Ram, 256GB SSD.

      \n\n

      Lenovo C340-14API Laptop

      \n
      ", - "author": "BrianMeerkatlol", - "publication_date": "2020-07-21T21:02:19Z", - "url": "https://www.reddit.com/r/linux/comments/hvdi3o/observations_on_a_linux_issue_with_35mm_earphones/", - "read": false, - "rule": 80, - "remote_identifier": "hvdi3o" - } -}, -{ - "model": "core.post", - "pk": 3170, - "fields": { - "created": "2020-07-21T20:14:50.509Z", - "modified": "2020-07-21T20:14:50.549Z", - "title": "South Korean distro HamoniKR OS has been added to Distrowatch", - "body": "", - "author": "TheHordeRisesAgain", - "publication_date": "2020-07-21T07:44:21Z", - "url": "https://www.reddit.com/r/linux/comments/hv1ug1/south_korean_distro_hamonikr_os_has_been_added_to/", - "read": false, - "rule": 80, - "remote_identifier": "hv1ug1" - } -}, -{ - "model": "core.post", - "pk": 3171, - "fields": { - "created": "2020-07-21T20:14:50.509Z", - "modified": "2020-07-21T20:14:50.559Z", - "title": "The Jailer is free! New release of the outstanding database subsetter and browser is available.", - "body": "", - "author": "Plane-Discussion", - "publication_date": "2020-07-21T12:53:54Z", - "url": "https://www.reddit.com/r/linux/comments/hv5b0j/the_jailer_is_free_new_release_of_the_outstanding/", - "read": false, - "rule": 80, - "remote_identifier": "hv5b0j" - } -}, -{ - "model": "core.post", - "pk": 3172, - "fields": { - "created": "2020-07-21T20:14:50.513Z", - "modified": "2020-07-21T20:14:50.563Z", - "title": "A few very well-aged excerpts from Microsoft\u2019s infamous 2004 \u201cGet the facts\u201d campaign, where they make the case for Windows servers being cheaper, more secure, and more performant than Linux servers", - "body": "
      \n

      Get the facts on Windows and Linux.

      \n\n

      Leading companies and third-party analysts confirm it: Windows has a lower total cost of ownership and outperforms Linux.

      \n\n

      ...

      \n\n

      -Security

      \n\n

      Windows Users Have Fewer Vulnerabilities

      \n
      \n\n

      And then literally the very next bullet point:

      \n\n
      \n

      -Featured Customer Case Study

      \n\n

      Equifax

      \n\n

      Equifax Sees 14 Percent Cost Savings

      \n\n

      Find out why Equifax, a global leader in transforming data into intelligence, selected Windows over Linux to enhance the speed and performance of its marketing services capabilities. Using Microsoft Windows Server System, the company has seen 14 percent in cost savings over Linux.

      \n
      \n\n

      Good thing they saved 14% and got all that extra security! Sure their website is janky and their login flow is downright horrifying (Check it out if you want to be amazed), but who could blame them? Linux is \u201cProhibitively Expensive, Extremely Complex, and Provides No Tangible Business Gains\u201d, Microsoft said so!

      \n\n

      Source: https://web.archive.org/web/20041027003759/http://www.microsoft.com/windowsserversystem/facts/default.mspx

      \n
      ", - "author": "kevinhaze", - "publication_date": "2020-07-20T21:42:15Z", - "url": "https://www.reddit.com/r/linux/comments/hus5lz/a_few_very_wellaged_excerpts_from_microsofts/", - "read": false, - "rule": 80, - "remote_identifier": "hus5lz" - } -}, -{ - "model": "core.post", - "pk": 3173, - "fields": { - "created": "2020-07-21T20:14:50.515Z", - "modified": "2020-07-21T20:14:50.566Z", - "title": "Are there are any professional audio recording studios or artists that use Linux?", - "body": "

      As the title says, who is using Linux as a professional audio engineer, producer, or artist? I am a former Mac user myself, and I am seeing people from time to time who have become disillusioned with what Apple has been doing for the past few years. However, I'm not sure if Linux really has a place for these people to land if they are serious about what they do.

      \n\n

      Fedora Design Suite and Ubuntu Studio are definitely encouraging to see, but what is their real-world usage like? Are we getting better with professional audio in Linux, or have things been stagnant for years?

      \n
      ", - "author": "RootHouston", - "publication_date": "2020-07-21T00:08:26Z", - "url": "https://www.reddit.com/r/linux/comments/huuxvq/are_there_are_any_professional_audio_recording/", - "read": false, - "rule": 80, - "remote_identifier": "huuxvq" - } -}, -{ - "model": "core.post", - "pk": 3174, - "fields": { - "created": "2020-07-21T20:14:50.515Z", - "modified": "2020-07-21T20:14:50.570Z", - "title": "When Linux had marketing", - "body": "", - "author": "Commodore256", - "publication_date": "2020-07-21T14:03:56Z", - "url": "https://www.reddit.com/r/linux/comments/hv65oa/when_linux_had_marketing/", - "read": false, - "rule": 80, - "remote_identifier": "hv65oa" - } -}, -{ - "model": "core.post", - "pk": 3175, - "fields": { - "created": "2020-07-21T20:14:50.520Z", - "modified": "2020-07-21T20:14:50.598Z", - "title": "Ward: Simple and minimalistic server dashboard", - "body": "

      Ward is a simple and and minimalistic server monitoring tool. Ward supports adaptive design system. Also it supports dark theme. It shows only principal information and can be used, if you want to see nice looking dashboard instead looking on bunch of numbers and graphs. Ward works nice on all popular operating systems, because it uses OSHI.

      \n\n

      https://preview.redd.it/gdppswc3a3c51.png?width=1448&format=png&auto=webp&s=0d6e10146c105ddcfd045dd59c970d4c127ddb8c

      \n\n

      https://github.com/B-Software/Ward

      \n
      ", - "author": "Pabyzu", - "publication_date": "2020-07-21T00:33:40Z", - "url": "https://www.reddit.com/r/linux/comments/huvea3/ward_simple_and_minimalistic_server_dashboard/", - "read": false, - "rule": 80, - "remote_identifier": "huvea3" - } -}, -{ - "model": "core.post", - "pk": 3176, - "fields": { - "created": "2020-07-21T20:14:50.522Z", - "modified": "2020-07-21T20:14:50.606Z", - "title": "WindowsFX - a good Windows alternative?", - "body": "

      I would personally like to hear some of your opinions (in the replies) about WindowsFX. What is WindowsFX you may ask? WindowsFX is a Brazilian linux distribution that is designed to look and act like Windows 10.

      \n\n

      Linux / WindowsFX is based off of Ubuntu, and uses Cinnamon as its DE. Upon first boot, normal Windows users can tell the difference. But if you were to put it in front of a non tech-savvy person, they wouldn't be able to tell the difference.

      \n\n

      Personally, with WSL on Windows, I see no need for a distro like this. However, as I said, I would like to hear your opinions on this distro.

      \n\n

      Video review here.

      \n
      ", - "author": "Demonitized101", - "publication_date": "2020-07-20T23:03:29Z", - "url": "https://www.reddit.com/r/linux/comments/hutpt5/windowsfx_a_good_windows_alternative/", - "read": false, - "rule": 80, - "remote_identifier": "hutpt5" - } -}, -{ - "model": "core.post", - "pk": 3177, - "fields": { - "created": "2020-07-21T20:14:50.775Z", - "modified": "2020-07-21T20:14:50.780Z", - "title": "Every day this good boy brings a carrot to his best buddy", - "body": "
      ", - "author": "TooShiftyForYou", - "publication_date": "2020-07-21T15:25:31Z", - "url": "https://www.reddit.com/r/aww/comments/hv7a8b/every_day_this_good_boy_brings_a_carrot_to_his/", - "read": false, - "rule": 81, - "remote_identifier": "hv7a8b" - } -}, -{ - "model": "core.post", - "pk": 3178, - "fields": { - "created": "2020-07-21T20:14:50.775Z", - "modified": "2020-07-25T20:08:34.264Z", - "title": "Kitten mimics his human petting the dog", - "body": "
      ", - "author": "SpecterAscendant", - "publication_date": "2020-07-21T14:56:57Z", - "url": "https://www.reddit.com/r/aww/comments/hv6ve3/kitten_mimics_his_human_petting_the_dog/", - "read": true, - "rule": 81, - "remote_identifier": "hv6ve3" - } -}, -{ - "model": "core.post", - "pk": 3179, - "fields": { - "created": "2020-07-21T20:14:50.775Z", - "modified": "2020-07-21T20:14:50.789Z", - "title": "My fox friend!", - "body": "
      ", - "author": "Zepantha", - "publication_date": "2020-07-21T14:27:25Z", - "url": "https://www.reddit.com/r/aww/comments/hv6gte/my_fox_friend/", - "read": false, - "rule": 81, - "remote_identifier": "hv6gte" - } -}, -{ - "model": "core.post", - "pk": 3180, - "fields": { - "created": "2020-07-21T20:14:50.775Z", - "modified": "2020-07-21T20:15:46.876Z", - "title": "Ducks annihilate peas", - "body": "
      ", - "author": "tommycalibre", - "publication_date": "2020-07-21T17:12:40Z", - "url": "https://www.reddit.com/r/aww/comments/hv9258/ducks_annihilate_peas/", - "read": true, - "rule": 81, - "remote_identifier": "hv9258" - } -}, -{ - "model": "core.post", - "pk": 3181, - "fields": { - "created": "2020-07-21T20:14:50.775Z", - "modified": "2020-07-21T20:14:50.797Z", - "title": "Wiggle it baby", - "body": "
      ", - "author": "neo_star", - "publication_date": "2020-07-21T18:44:31Z", - "url": "https://www.reddit.com/r/aww/comments/hvaucy/wiggle_it_baby/", - "read": false, - "rule": 81, - "remote_identifier": "hvaucy" - } -}, -{ - "model": "core.post", - "pk": 3182, - "fields": { - "created": "2020-07-21T20:14:50.776Z", - "modified": "2020-07-21T20:16:22.725Z", - "title": "I guess I should do this.. everyone seems to be liking little pups and kittens so.. Reddit, meet bailey", - "body": "
      \"I
      ", - "author": "X_XNOTHINGX_X", - "publication_date": "2020-07-21T14:15:08Z", - "url": "https://www.reddit.com/r/aww/comments/hv6b0a/i_guess_i_should_do_this_everyone_seems_to_be/", - "read": true, - "rule": 81, - "remote_identifier": "hv6b0a" - } -}, -{ - "model": "core.post", - "pk": 3183, - "fields": { - "created": "2020-07-21T20:14:50.776Z", - "modified": "2020-07-21T20:14:50.806Z", - "title": "The hat makes the crab.", - "body": "
      \"The
      ", - "author": "fujfuj", - "publication_date": "2020-07-21T14:48:40Z", - "url": "https://www.reddit.com/r/aww/comments/hv6rde/the_hat_makes_the_crab/", - "read": false, - "rule": 81, - "remote_identifier": "hv6rde" - } -}, -{ - "model": "core.post", - "pk": 3184, - "fields": { - "created": "2020-07-21T20:14:50.776Z", - "modified": "2020-07-21T20:14:50.812Z", - "title": "Baby bunny fits in hand", - "body": "
      ", - "author": "Hawken10", - "publication_date": "2020-07-21T12:31:30Z", - "url": "https://www.reddit.com/r/aww/comments/hv5253/baby_bunny_fits_in_hand/", - "read": false, - "rule": 81, - "remote_identifier": "hv5253" - } -}, -{ - "model": "core.post", - "pk": 3185, - "fields": { - "created": "2020-07-21T20:14:50.776Z", - "modified": "2020-07-21T20:14:50.818Z", - "title": "My cat and I, both pregnant", - "body": "
      \"My
      ", - "author": "nixdionisio", - "publication_date": "2020-07-21T11:06:25Z", - "url": "https://www.reddit.com/r/aww/comments/hv44m2/my_cat_and_i_both_pregnant/", - "read": false, - "rule": 81, - "remote_identifier": "hv44m2" - } -}, -{ - "model": "core.post", - "pk": 3186, - "fields": { - "created": "2020-07-21T20:14:50.776Z", - "modified": "2020-07-21T20:14:50.822Z", - "title": "Very sweet dance", - "body": "
      ", - "author": "Ashley1023", - "publication_date": "2020-07-21T13:03:03Z", - "url": "https://www.reddit.com/r/aww/comments/hv5ewq/very_sweet_dance/", - "read": false, - "rule": 81, - "remote_identifier": "hv5ewq" - } -}, -{ - "model": "core.post", - "pk": 3187, - "fields": { - "created": "2020-07-21T20:14:50.776Z", - "modified": "2020-07-21T20:14:50.825Z", - "title": "My local pet-store has a cat named Vegemite \u2764\ufe0f", - "body": "
      \"My
      ", - "author": "galinhad", - "publication_date": "2020-07-21T12:06:17Z", - "url": "https://www.reddit.com/r/aww/comments/hv4s5z/my_local_petstore_has_a_cat_named_vegemite/", - "read": false, - "rule": 81, - "remote_identifier": "hv4s5z" - } -}, -{ - "model": "core.post", - "pk": 3188, - "fields": { - "created": "2020-07-21T20:14:50.777Z", - "modified": "2020-07-21T20:15:01.459Z", - "title": "A teacher like that makes a huge difference", - "body": "
      ", - "author": "Unicornglitteryblood", - "publication_date": "2020-07-21T18:29:57Z", - "url": "https://www.reddit.com/r/aww/comments/hvajo9/a_teacher_like_that_makes_a_huge_difference/", - "read": true, - "rule": 81, - "remote_identifier": "hvajo9" - } -}, -{ - "model": "core.post", - "pk": 3189, - "fields": { - "created": "2020-07-21T20:14:50.777Z", - "modified": "2020-07-22T19:55:49.930Z", - "title": "Kitten Encounters Bubbly Water", - "body": "
      \"Kitten
      ", - "author": "DragonOBunny", - "publication_date": "2020-07-21T15:28:05Z", - "url": "https://www.reddit.com/r/aww/comments/hv7bis/kitten_encounters_bubbly_water/", - "read": true, - "rule": 81, - "remote_identifier": "hv7bis" - } -}, -{ - "model": "core.post", - "pk": 3190, - "fields": { - "created": "2020-07-21T20:14:50.777Z", - "modified": "2020-07-21T20:14:50.833Z", - "title": "Are These My Chickens Now?", - "body": "", - "author": "jasontaken", - "publication_date": "2020-07-21T09:55:36Z", - "url": "https://www.reddit.com/r/aww/comments/hv3de1/are_these_my_chickens_now/", - "read": false, - "rule": 81, - "remote_identifier": "hv3de1" - } -}, -{ - "model": "core.post", - "pk": 3191, - "fields": { - "created": "2020-07-21T20:14:50.777Z", - "modified": "2020-07-25T20:08:20.518Z", - "title": "Our St Bernard 6 months apart", - "body": "
      \"Our
      ", - "author": "ryan3105", - "publication_date": "2020-07-21T18:00:04Z", - "url": "https://www.reddit.com/r/aww/comments/hv9yea/our_st_bernard_6_months_apart/", - "read": true, - "rule": 81, - "remote_identifier": "hv9yea" - } -}, -{ - "model": "core.post", - "pk": 3192, - "fields": { - "created": "2020-07-21T20:14:50.777Z", - "modified": "2020-07-21T20:14:50.837Z", - "title": "Father and child in sync", - "body": "
      ", - "author": "Araragi_Monogatari", - "publication_date": "2020-07-21T08:29:18Z", - "url": "https://www.reddit.com/r/aww/comments/hv2enj/father_and_child_in_sync/", - "read": false, - "rule": 81, - "remote_identifier": "hv2enj" - } -}, -{ - "model": "core.post", - "pk": 3193, - "fields": { - "created": "2020-07-21T20:14:50.778Z", - "modified": "2020-07-21T20:14:50.840Z", - "title": "A meme is born", - "body": "
      \"A
      ", - "author": "Unicornglitteryblood", - "publication_date": "2020-07-21T18:55:04Z", - "url": "https://www.reddit.com/r/aww/comments/hvb1vh/a_meme_is_born/", - "read": false, - "rule": 81, - "remote_identifier": "hvb1vh" - } -}, -{ - "model": "core.post", - "pk": 3194, - "fields": { - "created": "2020-07-21T20:14:50.778Z", - "modified": "2020-07-21T20:14:50.842Z", - "title": "She bites, then she sleeps, then bites again, then sleeps again. \ud83d\ude02", - "body": "
      ", - "author": "earlymauvs", - "publication_date": "2020-07-21T11:34:19Z", - "url": "https://www.reddit.com/r/aww/comments/hv4fat/she_bites_then_she_sleeps_then_bites_again_then/", - "read": false, - "rule": 81, - "remote_identifier": "hv4fat" - } -}, -{ - "model": "core.post", - "pk": 3195, - "fields": { - "created": "2020-07-21T20:14:50.778Z", - "modified": "2020-07-21T20:14:50.844Z", - "title": "Nothing calmer that 2 ginger cats rubbing heads and showing their love in morning", - "body": "
      \"Nothing
      ", - "author": "Apotheosis33", - "publication_date": "2020-07-21T08:39:24Z", - "url": "https://www.reddit.com/r/aww/comments/hv2j2g/nothing_calmer_that_2_ginger_cats_rubbing_heads/", - "read": false, - "rule": 81, - "remote_identifier": "hv2j2g" - } -}, -{ - "model": "core.post", - "pk": 3196, - "fields": { - "created": "2020-07-21T20:14:50.778Z", - "modified": "2020-07-21T20:14:50.851Z", - "title": "Ring Tailed Possum", - "body": "", - "author": "Wayward-Delver", - "publication_date": "2020-07-21T11:23:51Z", - "url": "https://www.reddit.com/r/aww/comments/hv4b9e/ring_tailed_possum/", - "read": false, - "rule": 81, - "remote_identifier": "hv4b9e" - } -}, -{ - "model": "core.post", - "pk": 3197, - "fields": { - "created": "2020-07-21T20:14:50.778Z", - "modified": "2020-07-21T20:14:50.854Z", - "title": "Baby scooby in sad mood....", - "body": "
      \"Baby
      ", - "author": "deepanshuahiroo7", - "publication_date": "2020-07-21T15:12:23Z", - "url": "https://www.reddit.com/r/aww/comments/hv73ft/baby_scooby_in_sad_mood/", - "read": false, - "rule": 81, - "remote_identifier": "hv73ft" - } -}, -{ - "model": "core.post", - "pk": 3198, - "fields": { - "created": "2020-07-21T20:14:50.779Z", - "modified": "2020-07-21T20:14:50.856Z", - "title": "New friends!", - "body": "
      \"New
      ", - "author": "HelentotheKeller", - "publication_date": "2020-07-21T13:10:48Z", - "url": "https://www.reddit.com/r/aww/comments/hv5i6i/new_friends/", - "read": false, - "rule": 81, - "remote_identifier": "hv5i6i" - } -}, -{ - "model": "core.post", - "pk": 3199, - "fields": { - "created": "2020-07-21T20:14:50.779Z", - "modified": "2020-07-21T20:14:50.858Z", - "title": "When you haven't chewed anything for 1 second", - "body": "
      \"When
      ", - "author": "Tanay4", - "publication_date": "2020-07-21T10:26:53Z", - "url": "https://www.reddit.com/r/aww/comments/hv3pl0/when_you_havent_chewed_anything_for_1_second/", - "read": false, - "rule": 81, - "remote_identifier": "hv3pl0" - } -}, -{ - "model": "core.post", - "pk": 3200, - "fields": { - "created": "2020-07-21T20:14:50.779Z", - "modified": "2020-07-21T20:17:01.490Z", - "title": "Mango Derp", - "body": "
      \"Mango
      ", - "author": "sheetglass", - "publication_date": "2020-07-21T13:27:26Z", - "url": "https://www.reddit.com/r/aww/comments/hv5p8s/mango_derp/", - "read": true, - "rule": 81, - "remote_identifier": "hv5p8s" - } -}, -{ - "model": "core.post", - "pk": 3201, - "fields": { - "created": "2020-07-21T20:14:50.779Z", - "modified": "2020-07-21T20:14:50.863Z", - "title": "My guy turns 20 next month", - "body": "
      \"My
      ", - "author": "alozsoc", - "publication_date": "2020-07-21T06:34:26Z", - "url": "https://www.reddit.com/r/aww/comments/hv0xp1/my_guy_turns_20_next_month/", - "read": false, - "rule": 81, - "remote_identifier": "hv0xp1" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add log entry", - "content_type": [ - "admin", - "logentry" - ], - "codename": "add_logentry" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change log entry", - "content_type": [ - "admin", - "logentry" - ], - "codename": "change_logentry" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete log entry", - "content_type": [ - "admin", - "logentry" - ], - "codename": "delete_logentry" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view log entry", - "content_type": [ - "admin", - "logentry" - ], - "codename": "view_logentry" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add permission", - "content_type": [ - "auth", - "permission" - ], - "codename": "add_permission" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change permission", - "content_type": [ - "auth", - "permission" - ], - "codename": "change_permission" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete permission", - "content_type": [ - "auth", - "permission" - ], - "codename": "delete_permission" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view permission", - "content_type": [ - "auth", - "permission" - ], - "codename": "view_permission" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add group", - "content_type": [ - "auth", - "group" - ], - "codename": "add_group" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change group", - "content_type": [ - "auth", - "group" - ], - "codename": "change_group" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete group", - "content_type": [ - "auth", - "group" - ], - "codename": "delete_group" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view group", - "content_type": [ - "auth", - "group" - ], - "codename": "view_group" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add content type", - "content_type": [ - "contenttypes", - "contenttype" - ], - "codename": "add_contenttype" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change content type", - "content_type": [ - "contenttypes", - "contenttype" - ], - "codename": "change_contenttype" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete content type", - "content_type": [ - "contenttypes", - "contenttype" - ], - "codename": "delete_contenttype" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view content type", - "content_type": [ - "contenttypes", - "contenttype" - ], - "codename": "view_contenttype" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add session", - "content_type": [ - "sessions", - "session" - ], - "codename": "add_session" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change session", - "content_type": [ - "sessions", - "session" - ], - "codename": "change_session" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete session", - "content_type": [ - "sessions", - "session" - ], - "codename": "delete_session" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view session", - "content_type": [ - "sessions", - "session" - ], - "codename": "view_session" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add crontab", - "content_type": [ - "django_celery_beat", - "crontabschedule" - ], - "codename": "add_crontabschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change crontab", - "content_type": [ - "django_celery_beat", - "crontabschedule" - ], - "codename": "change_crontabschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete crontab", - "content_type": [ - "django_celery_beat", - "crontabschedule" - ], - "codename": "delete_crontabschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view crontab", - "content_type": [ - "django_celery_beat", - "crontabschedule" - ], - "codename": "view_crontabschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add interval", - "content_type": [ - "django_celery_beat", - "intervalschedule" - ], - "codename": "add_intervalschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change interval", - "content_type": [ - "django_celery_beat", - "intervalschedule" - ], - "codename": "change_intervalschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete interval", - "content_type": [ - "django_celery_beat", - "intervalschedule" - ], - "codename": "delete_intervalschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view interval", - "content_type": [ - "django_celery_beat", - "intervalschedule" - ], - "codename": "view_intervalschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add periodic task", - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "codename": "add_periodictask" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change periodic task", - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "codename": "change_periodictask" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete periodic task", - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "codename": "delete_periodictask" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view periodic task", - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "codename": "view_periodictask" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add periodic tasks", - "content_type": [ - "django_celery_beat", - "periodictasks" - ], - "codename": "add_periodictasks" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change periodic tasks", - "content_type": [ - "django_celery_beat", - "periodictasks" - ], - "codename": "change_periodictasks" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete periodic tasks", - "content_type": [ - "django_celery_beat", - "periodictasks" - ], - "codename": "delete_periodictasks" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view periodic tasks", - "content_type": [ - "django_celery_beat", - "periodictasks" - ], - "codename": "view_periodictasks" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add solar event", - "content_type": [ - "django_celery_beat", - "solarschedule" - ], - "codename": "add_solarschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change solar event", - "content_type": [ - "django_celery_beat", - "solarschedule" - ], - "codename": "change_solarschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete solar event", - "content_type": [ - "django_celery_beat", - "solarschedule" - ], - "codename": "delete_solarschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view solar event", - "content_type": [ - "django_celery_beat", - "solarschedule" - ], - "codename": "view_solarschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add clocked", - "content_type": [ - "django_celery_beat", - "clockedschedule" - ], - "codename": "add_clockedschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change clocked", - "content_type": [ - "django_celery_beat", - "clockedschedule" - ], - "codename": "change_clockedschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete clocked", - "content_type": [ - "django_celery_beat", - "clockedschedule" - ], - "codename": "delete_clockedschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view clocked", - "content_type": [ - "django_celery_beat", - "clockedschedule" - ], - "codename": "view_clockedschedule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add registration profile", - "content_type": [ - "registration", - "registrationprofile" - ], - "codename": "add_registrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change registration profile", - "content_type": [ - "registration", - "registrationprofile" - ], - "codename": "change_registrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete registration profile", - "content_type": [ - "registration", - "registrationprofile" - ], - "codename": "delete_registrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view registration profile", - "content_type": [ - "registration", - "registrationprofile" - ], - "codename": "view_registrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add supervised registration profile", - "content_type": [ - "registration", - "supervisedregistrationprofile" - ], - "codename": "add_supervisedregistrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change supervised registration profile", - "content_type": [ - "registration", - "supervisedregistrationprofile" - ], - "codename": "change_supervisedregistrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete supervised registration profile", - "content_type": [ - "registration", - "supervisedregistrationprofile" - ], - "codename": "delete_supervisedregistrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view supervised registration profile", - "content_type": [ - "registration", - "supervisedregistrationprofile" - ], - "codename": "view_supervisedregistrationprofile" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add access attempt", - "content_type": [ - "axes", - "accessattempt" - ], - "codename": "add_accessattempt" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change access attempt", - "content_type": [ - "axes", - "accessattempt" - ], - "codename": "change_accessattempt" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete access attempt", - "content_type": [ - "axes", - "accessattempt" - ], - "codename": "delete_accessattempt" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view access attempt", - "content_type": [ - "axes", - "accessattempt" - ], - "codename": "view_accessattempt" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add access log", - "content_type": [ - "axes", - "accesslog" - ], - "codename": "add_accesslog" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change access log", - "content_type": [ - "axes", - "accesslog" - ], - "codename": "change_accesslog" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete access log", - "content_type": [ - "axes", - "accesslog" - ], - "codename": "delete_accesslog" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view access log", - "content_type": [ - "axes", - "accesslog" - ], - "codename": "view_accesslog" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add user", - "content_type": [ - "accounts", - "user" - ], - "codename": "add_user" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change user", - "content_type": [ - "accounts", - "user" - ], - "codename": "change_user" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete user", - "content_type": [ - "accounts", - "user" - ], - "codename": "delete_user" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view user", - "content_type": [ - "accounts", - "user" - ], - "codename": "view_user" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add post", - "content_type": [ - "core", - "post" - ], - "codename": "add_post" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change post", - "content_type": [ - "core", - "post" - ], - "codename": "change_post" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete post", - "content_type": [ - "core", - "post" - ], - "codename": "delete_post" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view post", - "content_type": [ - "core", - "post" - ], - "codename": "view_post" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add Category", - "content_type": [ - "core", - "category" - ], - "codename": "add_category" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change Category", - "content_type": [ - "core", - "category" - ], - "codename": "change_category" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete Category", - "content_type": [ - "core", - "category" - ], - "codename": "delete_category" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view Category", - "content_type": [ - "core", - "category" - ], - "codename": "view_category" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can add collection rule", - "content_type": [ - "collection", - "collectionrule" - ], - "codename": "add_collectionrule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can change collection rule", - "content_type": [ - "collection", - "collectionrule" - ], - "codename": "change_collectionrule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can delete collection rule", - "content_type": [ - "collection", - "collectionrule" - ], - "codename": "delete_collectionrule" - } -}, -{ - "model": "auth.permission", - "fields": { - "name": "Can view collection rule", - "content_type": [ - "collection", - "collectionrule" - ], - "codename": "view_collectionrule" - } -}, -{ - "model": "accounts.user", - "fields": { - "password": "pbkdf2_sha256$180000$U9a2CS9X0b8Y$T6bD/VoUOFoGNIp16aFlOL0N7q0e6A3I97ypm/AhsGo=", - "last_login": "2020-07-21T20:14:35.966Z", - "is_superuser": true, - "first_name": "", - "last_name": "", - "is_staff": true, - "is_active": true, - "date_joined": "2019-07-18T18:52:36.080Z", - "email": "sonny@bakker.nl", - "task": 10, - "reddit_refresh_token": null, - "reddit_access_token": null, - "groups": [], - "user_permissions": [] - } -}, -{ - "model": "core.category", - "pk": 8, - "fields": { - "created": "2019-11-17T19:37:24.671Z", - "modified": "2019-11-18T19:59:55.010Z", - "name": "World news", - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "core.category", - "pk": 9, - "fields": { - "created": "2019-11-17T19:37:26.161Z", - "modified": "2020-05-30T13:36:10.509Z", - "name": "Tech", - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 3, - "fields": { - "created": "2019-07-14T13:08:10.374Z", - "modified": "2020-07-14T11:45:30.680Z", - "name": "Hackers News", - "type": "feed", - "url": "https://news.ycombinator.com/rss", - "website_url": "https://news.ycombinator.com/", - "favicon": "https://news.ycombinator.com/favicon.ico", - "timezone": "UTC", - "category": 9, - "last_suceeded": "2020-07-14T11:45:30.477Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 4, - "fields": { - "created": "2019-07-20T11:24:32.745Z", - "modified": "2020-07-14T11:45:29.357Z", - "name": "BBC", - "type": "feed", - "url": "http://feeds.bbci.co.uk/news/world/rss.xml", - "website_url": "https://www.bbc.co.uk/news/", - "favicon": "https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png", - "timezone": "UTC", - "category": 8, - "last_suceeded": "2020-07-14T11:45:28.863Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 5, - "fields": { - "created": "2019-07-20T11:24:50.411Z", - "modified": "2020-07-14T11:45:30.063Z", - "name": "Ars Technica", - "type": "feed", - "url": "http://feeds.arstechnica.com/arstechnica/index?fmt=xml", - "website_url": "https://arstechnica.com", - "favicon": "https://cdn.arstechnica.net/favicon.ico", - "timezone": "UTC", - "category": 9, - "last_suceeded": "2020-07-14T11:45:29.810Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 6, - "fields": { - "created": "2019-07-20T11:25:02.089Z", - "modified": "2020-07-14T11:45:30.473Z", - "name": "The Guardian", - "type": "feed", - "url": "https://www.theguardian.com/world/rss", - "website_url": "https://www.theguardian.com/world", - "favicon": "https://assets.guim.co.uk/images/favicons/873381bf11d58e20f551905d51575117/72x72.png", - "timezone": "UTC", - "category": 8, - "last_suceeded": "2020-07-14T11:45:30.181Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 7, - "fields": { - "created": "2019-07-20T11:25:30.121Z", - "modified": "2020-07-14T11:45:29.807Z", - "name": "Tweakers", - "type": "feed", - "url": "http://feeds.feedburner.com/tweakers/mixed?fmt=xml", - "website_url": "https://tweakers.net/", - "favicon": null, - "timezone": "UTC", - "category": 9, - "last_suceeded": "2020-07-14T11:45:29.525Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 8, - "fields": { - "created": "2019-07-20T11:25:46.256Z", - "modified": "2020-07-14T11:45:30.179Z", - "name": "The Verge", - "type": "feed", - "url": "https://www.theverge.com/rss/index.xml", - "website_url": "https://www.theverge.com/", - "favicon": "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395367/favicon-16x16.0.png", - "timezone": "UTC", - "category": 9, - "last_suceeded": "2020-07-14T11:45:30.066Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 9, - "fields": { - "created": "2019-11-24T15:28:41.399Z", - "modified": "2020-07-14T11:45:29.522Z", - "name": "NOS", - "type": "feed", - "url": "http://feeds.nos.nl/nosnieuwsalgemeen", - "website_url": null, - "favicon": null, - "timezone": "Europe/Amsterdam", - "category": 8, - "last_suceeded": "2020-07-14T11:45:29.362Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 80, - "fields": { - "created": "2020-07-08T19:30:10.638Z", - "modified": "2020-07-21T20:14:50.609Z", - "name": "Linux subreddit", - "type": "subreddit", - "url": "https://oauth.reddit.com/r/linux/hot", - "website_url": null, - "favicon": null, - "timezone": "UTC", - "category": 9, - "last_suceeded": "2020-07-21T20:14:50.492Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 81, - "fields": { - "created": "2020-07-08T19:30:33.590Z", - "modified": "2020-07-21T20:14:50.865Z", - "name": "AWW subreddit", - "type": "subreddit", - "url": "https://oauth.reddit.com/r/aww/hot", - "website_url": null, - "favicon": null, - "timezone": "UTC", - "category": 8, - "last_suceeded": "2020-07-21T20:14:50.768Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "collection.collectionrule", - "pk": 82, - "fields": { - "created": "2020-07-20T19:29:37.675Z", - "modified": "2020-07-21T20:14:50.489Z", - "name": "Star citizen subreddit", - "type": "subreddit", - "url": "https://oauth.reddit.com/r/starcitizen/hot.json", - "website_url": null, - "favicon": null, - "timezone": "UTC", - "category": 9, - "last_suceeded": "2020-07-21T20:14:50.355Z", - "succeeded": true, - "error": null, - "enabled": true, - "user": [ - "sonny@bakker.nl" - ] - } -}, -{ - "model": "admin.logentry", - "pk": 1, - "fields": { - "action_time": "2020-05-24T18:38:44.624Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "django_celery_beat", - "intervalschedule" - ], - "object_id": "5", - "object_repr": "every 4 hours", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 2, - "fields": { - "action_time": "2020-05-24T18:38:46.689Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "object_id": "10", - "object_repr": "sonny@bakker.nl-collection-task: every 4 hours", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Interval Schedule\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 3, - "fields": { - "action_time": "2020-05-24T18:39:09.203Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "object_id": "26", - "object_repr": "sonnyba871@gmail.com-collection-task: every hour", - "action_flag": 3, - "change_message": "" - } -}, -{ - "model": "admin.logentry", - "pk": 4, - "fields": { - "action_time": "2020-05-24T19:46:50.248Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "object_id": "10", - "object_repr": "sonny@bakker.nl-collection-task: every 4 hours", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Positional Arguments\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 5, - "fields": { - "action_time": "2020-07-07T19:37:57.086Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "accounts", - "user" - ], - "object_id": "1", - "object_repr": "sonny@bakker.nl", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Reddit refresh token\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 6, - "fields": { - "action_time": "2020-07-07T19:39:46.160Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "object_id": "10", - "object_repr": "sonny@bakker.nl-collection-task: every 4 hours", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Task (registered)\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 7, - "fields": { - "action_time": "2020-07-08T19:29:27.025Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "django_celery_beat", - "periodictask" - ], - "object_id": "11", - "object_repr": "Reddit collection task: every 4 hours", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 8, - "fields": { - "action_time": "2020-07-14T11:46:50.039Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "accounts", - "user" - ], - "object_id": "1", - "object_repr": "sonny@bakker.nl", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Reddit access token\", \"Reddit refresh token\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 9, - "fields": { - "action_time": "2020-07-18T19:08:33.997Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "collection", - "collectionrule" - ], - "object_id": "81", - "object_repr": "AWW subreddit", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Url\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 10, - "fields": { - "action_time": "2020-07-18T19:08:44.063Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "collection", - "collectionrule" - ], - "object_id": "80", - "object_repr": "Linux subreddit", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Url\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 11, - "fields": { - "action_time": "2020-07-18T19:17:25.213Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "core", - "post" - ], - "object_id": "2336", - "object_repr": "Post-2336", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 12, - "fields": { - "action_time": "2020-07-18T19:17:40.596Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "core", - "post" - ], - "object_id": "2336", - "object_repr": "Post-2336", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 13, - "fields": { - "action_time": "2020-07-19T10:55:55.807Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "core", - "post" - ], - "object_id": "2764", - "object_repr": "Post-2764", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 14, - "fields": { - "action_time": "2020-07-19T10:57:40.643Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "core", - "post" - ], - "object_id": "2764", - "object_repr": "Post-2764", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 15, - "fields": { - "action_time": "2020-07-19T10:58:05.823Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "core", - "post" - ], - "object_id": "2764", - "object_repr": "Post-2764", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 16, - "fields": { - "action_time": "2020-07-26T09:51:52.478Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "accounts", - "user" - ], - "object_id": "1", - "object_repr": "sonny@bakker.nl", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"First name\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 17, - "fields": { - "action_time": "2020-07-26T09:52:04.691Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "accounts", - "user" - ], - "object_id": "1", - "object_repr": "sonny@bakker.nl", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"password\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 18, - "fields": { - "action_time": "2020-07-26T09:52:12.392Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "accounts", - "user" - ], - "object_id": "1", - "object_repr": "sonny@bakker.nl", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"First name\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 19, - "fields": { - "action_time": "2020-07-26T09:56:15.949Z", - "user": [ - "sonny@bakker.nl" - ], - "content_type": [ - "accounts", - "user" - ], - "object_id": "1", - "object_repr": "sonny@bakker.nl", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Reddit access token\", \"Reddit refresh token\"]}}]" - } -} -] +[ +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "admin", + "model": "logentry" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "auth", + "model": "permission" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "auth", + "model": "group" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "contenttypes", + "model": "contenttype" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "sessions", + "model": "session" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "crontabschedule" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "intervalschedule" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "periodictask" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "periodictasks" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "solarschedule" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "django_celery_beat", + "model": "clockedschedule" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "registration", + "model": "registrationprofile" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "registration", + "model": "supervisedregistrationprofile" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "axes", + "model": "accessattempt" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "axes", + "model": "accesslog" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "accounts", + "model": "user" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "core", + "model": "post" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "core", + "model": "category" + } +}, +{ + "model": "contenttypes.contenttype", + "fields": { + "app_label": "collection", + "model": "collectionrule" + } +}, +{ + "model": "sessions.session", + "pk": "3sumq22krk8tsvexcs4b8czu82yhvuer", + "fields": { + "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", + "expire_date": "2020-05-16T18:29:04.049Z" + } +}, +{ + "model": "sessions.session", + "pk": "8ix6bdwf2ywk0eir1hb062dhfh9xit85", + "fields": { + "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", + "expire_date": "2020-07-21T19:36:54.530Z" + } +}, +{ + "model": "sessions.session", + "pk": "d4wophwpjm8z96doe8iddvhdv9yfafyx", + "fields": { + "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", + "expire_date": "2020-06-07T19:45:49.727Z" + } +}, +{ + "model": "sessions.session", + "pk": "g23ziz66li5zx8nd8cewb3vevdxhjkm0", + "fields": { + "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", + "expire_date": "2020-06-30T06:55:50.747Z" + } +}, +{ + "model": "sessions.session", + "pk": "jwn66dptmdkm6hom2ns3j288aaxqtyjd", + "fields": { + "session_data": "OWZkZTQyZDQ2NzNkYzdkOTBhM2ZlOWU3MDhhNDkyMWQ0MDdmZTc5ODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiJhZTMwMWFlMzI5OGFlOThkNjY1MTY1NDIxM2EyMmM0NDA0Y2FkZTc3In0=", + "expire_date": "2020-06-07T18:38:19.116Z" + } +}, +{ + "model": "sessions.session", + "pk": "wjz6kwg5e5ciemre0l0wwyrcwcj2gyg6", + "fields": { + "session_data": "MWU5ODBjY2QyOTFhMmRiY2QyYjQwZjQ3MmMwYmExYjBlYTkxNTcwODp7Il9hdXRoX3VzZXJfaWQiOiIxIiwiX2F1dGhfdXNlcl9iYWNrZW5kIjoiZGphbmdvLmNvbnRyaWIuYXV0aC5iYWNrZW5kcy5Nb2RlbEJhY2tlbmQiLCJfYXV0aF91c2VyX2hhc2giOiI0YWZkYTkxNzU5ZDBhZDZmMjg1ZTQyOGY0OTUxN2M5MTJhMmM5NWIyIn0=", + "expire_date": "2020-08-09T09:52:04.705Z" + } +}, +{ + "model": "django_celery_beat.intervalschedule", + "pk": 1, + "fields": { + "every": 5, + "period": "minutes" + } +}, +{ + "model": "django_celery_beat.intervalschedule", + "pk": 2, + "fields": { + "every": 15, + "period": "minutes" + } +}, +{ + "model": "django_celery_beat.intervalschedule", + "pk": 3, + "fields": { + "every": 30, + "period": "minutes" + } +}, +{ + "model": "django_celery_beat.intervalschedule", + "pk": 4, + "fields": { + "every": 1, + "period": "hours" + } +}, +{ + "model": "django_celery_beat.intervalschedule", + "pk": 5, + "fields": { + "every": 4, + "period": "hours" + } +}, +{ + "model": "django_celery_beat.crontabschedule", + "pk": 1, + "fields": { + "minute": "0", + "hour": "4", + "day_of_week": "*", + "day_of_month": "*", + "month_of_year": "*", + "timezone": "UTC" + } +}, +{ + "model": "django_celery_beat.periodictasks", + "pk": 1, + "fields": { + "last_update": "2020-07-26T09:47:48.298Z" + } +}, +{ + "model": "django_celery_beat.periodictask", + "pk": 1, + "fields": { + "name": "celery.backend_cleanup", + "task": "celery.backend_cleanup", + "interval": null, + "crontab": 1, + "solar": null, + "clocked": null, + "args": "[]", + "kwargs": "{}", + "queue": null, + "exchange": null, + "routing_key": null, + "headers": "{}", + "priority": null, + "expires": null, + "expire_seconds": 43200, + "one_off": false, + "start_time": null, + "enabled": true, + "last_run_at": "2020-07-26T09:47:48.322Z", + "total_run_count": 17, + "date_changed": "2020-07-26T09:47:50.362Z", + "description": "" + } +}, +{ + "model": "django_celery_beat.periodictask", + "pk": 10, + "fields": { + "name": "sonny@bakker.nl-collection-task", + "task": "FeedTask", + "interval": 5, + "crontab": null, + "solar": null, + "clocked": null, + "args": "[1]", + "kwargs": "{}", + "queue": null, + "exchange": null, + "routing_key": null, + "headers": "{}", + "priority": null, + "expires": null, + "expire_seconds": null, + "one_off": false, + "start_time": null, + "enabled": false, + "last_run_at": "2020-07-14T11:45:26.209Z", + "total_run_count": 307, + "date_changed": "2020-07-14T11:45:41.282Z", + "description": "" + } +}, +{ + "model": "django_celery_beat.periodictask", + "pk": 11, + "fields": { + "name": "Reddit collection task", + "task": "RedditTask", + "interval": 5, + "crontab": null, + "solar": null, + "clocked": null, + "args": "[]", + "kwargs": "{}", + "queue": null, + "exchange": null, + "routing_key": null, + "headers": "{}", + "priority": null, + "expires": null, + "expire_seconds": null, + "one_off": false, + "start_time": null, + "enabled": false, + "last_run_at": null, + "total_run_count": 4, + "date_changed": "2020-07-14T11:45:41.316Z", + "description": "" + } +}, +{ + "model": "core.post", + "pk": 3061, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:14:50.423Z", + "title": "Star Citizen: Question and Answer Thread", + "body": "

      Welcome to the Star Citizen question and answer thread. Feel free to ask any questions you have related to SC here!

      \n\n\n\n

      Useful Links and Resources:

      \n\n

      Star Citizen Wiki - The biggest and best wiki resource dedicated to Star Citizen

      \n\n

      Star Citizen FAQ - Chances the answer you need is here.

      \n\n

      Discord Help Channel - Often times community members will be here to help you with issues.

      \n\n

      Referral Code Randomizer - Use this when creating a new account to get 5000 extra UEC.

      \n\n

      Download Star Citizen - Get the latest version of Star Citizen here

      \n\n

      Current Game Features - Click here to see what you can currently do in Star Citizen.

      \n\n

      Development Roadmap - The current development status of up and coming Star Citizen features.

      \n\n

      Pledge FAQ - Official FAQ regarding spending money on the game.

      \n
      ", + "author": "UEE_Central_Computer", + "publication_date": "2020-07-20T14:00:10Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huk04t/star_citizen_question_and_answer_thread/", + "read": false, + "rule": 82, + "remote_identifier": "huk04t" + } +}, +{ + "model": "core.post", + "pk": 3062, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:33:37.019Z", + "title": "Peace and Quiet", + "body": "
      \"Peace
      ", + "author": "SourMemeNZ", + "publication_date": "2020-07-20T14:09:49Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huk4ib/peace_and_quiet/", + "read": true, + "rule": 82, + "remote_identifier": "huk4ib" + } +}, +{ + "model": "core.post", + "pk": 3063, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:14:50.463Z", + "title": "Y'all are probably sick of em by now but here's my LEGO Mercury Star Runner (MSR).", + "body": "
      \"Y'all
      ", + "author": "osamadabinman", + "publication_date": "2020-07-20T19:53:23Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hupzqa/yall_are_probably_sick_of_em_by_now_but_heres_my/", + "read": true, + "rule": 82, + "remote_identifier": "hupzqa" + } +}, +{ + "model": "core.post", + "pk": 3064, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:17:12.253Z", + "title": "Damned Space Invaders and their pixel weapons!", + "body": "
      \"Damned
      ", + "author": "Akaradrin", + "publication_date": "2020-07-20T14:26:18Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hukckf/damned_space_invaders_and_their_pixel_weapons/", + "read": true, + "rule": 82, + "remote_identifier": "hukckf" + } +}, +{ + "model": "core.post", + "pk": 3065, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:32:35.578Z", + "title": "The sky is no longer the limit", + "body": "
      \"The
      ", + "author": "CyberTill", + "publication_date": "2020-07-20T14:11:31Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huk5b8/the_sky_is_no_longer_the_limit/", + "read": false, + "rule": 82, + "remote_identifier": "huk5b8" + } +}, +{ + "model": "core.post", + "pk": 3066, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:17:23.282Z", + "title": "Terrapin Hover Mode Gameplay [Full Video in Comments]", + "body": "
      ", + "author": "Didactic_Tomato", + "publication_date": "2020-07-20T11:01:13Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hui1gv/terrapin_hover_mode_gameplay_full_video_in/", + "read": true, + "rule": 82, + "remote_identifier": "hui1gv" + } +}, +{ + "model": "core.post", + "pk": 3067, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:17:44.250Z", + "title": "honestly", + "body": "
      \"honestly\"
      ", + "author": "Beatlead", + "publication_date": "2020-07-20T18:24:07Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huo96t/honestly/", + "read": true, + "rule": 82, + "remote_identifier": "huo96t" + } +}, +{ + "model": "core.post", + "pk": 3068, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:32:35.584Z", + "title": "As a paranoiac and tired of checking if door was closed, saved to f4 thoses \"security cam\" positions, could be usefull for larger ships :)", + "body": "", + "author": "icwiener__", + "publication_date": "2020-07-20T13:03:33Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hujchz/as_a_paranoiac_and_tired_of_checking_if_door_was/", + "read": false, + "rule": 82, + "remote_identifier": "hujchz" + } +}, +{ + "model": "core.post", + "pk": 3069, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:33:59.158Z", + "title": "Station Manager: \"You're too fat, we won't let you in, go and fall on Lorville. Thank you for your call!\" Me: \"okay :'(\"", + "body": "
      \"Station
      ", + "author": "Shaman_N_One", + "publication_date": "2020-07-20T11:33:38Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huidlu/station_manager_youre_too_fat_we_wont_let_you_in/", + "read": true, + "rule": 82, + "remote_identifier": "huidlu" + } +}, +{ + "model": "core.post", + "pk": 3070, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:32:35.588Z", + "title": "[PTU Bug Hunt Request] Packet Loss", + "body": "", + "author": "Rainwalker007", + "publication_date": "2020-07-20T18:38:03Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huoicq/ptu_bug_hunt_request_packet_loss/", + "read": false, + "rule": 82, + "remote_identifier": "huoicq" + } +}, +{ + "model": "core.post", + "pk": 3071, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:17:52.092Z", + "title": "Anyone able to explain these \"trail frames\"?", + "body": "
      \"Anyone
      ", + "author": "Abnormal_Sloth", + "publication_date": "2020-07-20T17:11:32Z", + "url": "https://www.reddit.com/r/starcitizen/comments/humyeq/anyone_able_to_explain_these_trail_frames/", + "read": true, + "rule": 82, + "remote_identifier": "humyeq" + } +}, +{ + "model": "core.post", + "pk": 3072, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:32:35.593Z", + "title": "#BringBackBugSmasher - A long forgotten legendary video content", + "body": "", + "author": "MasterBoring", + "publication_date": "2020-07-20T18:05:54Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hunx77/bringbackbugsmasher_a_long_forgotten_legendary/", + "read": false, + "rule": 82, + "remote_identifier": "hunx77" + } +}, +{ + "model": "core.post", + "pk": 3073, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:33:22.601Z", + "title": "Oracle Helmet [in-game screenshot; downsampled to 4k]", + "body": "
      \"Oracle
      ", + "author": "mr-hasgaha", + "publication_date": "2020-07-20T17:39:34Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hung0b/oracle_helmet_ingame_screenshot_downsampled_to_4k/", + "read": true, + "rule": 82, + "remote_identifier": "hung0b" + } +}, +{ + "model": "core.post", + "pk": 3074, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:34:42.578Z", + "title": "Testing 3.10 - Gladius in decoupled mode", + "body": "
      ", + "author": "DarkConstant", + "publication_date": "2020-07-19T21:26:52Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hu6f1h/testing_310_gladius_in_decoupled_mode/", + "read": true, + "rule": 82, + "remote_identifier": "hu6f1h" + } +}, +{ + "model": "core.post", + "pk": 3075, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:34:29.424Z", + "title": "Day 3, I can't stop taking pictures with my Carrack. Send help", + "body": "
      \"Day
      ", + "author": "CyberTill", + "publication_date": "2020-07-20T01:58:15Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huazyy/day_3_i_cant_stop_taking_pictures_with_my_carrack/", + "read": true, + "rule": 82, + "remote_identifier": "huazyy" + } +}, +{ + "model": "core.post", + "pk": 3076, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:32:35.602Z", + "title": "I used to enjoy flying between the buildings of new babbage, I mean before the NFZ \"improvement\"", + "body": "
      \"I
      ", + "author": "shoeii", + "publication_date": "2020-07-20T16:40:26Z", + "url": "https://www.reddit.com/r/starcitizen/comments/humet2/i_used_to_enjoy_flying_between_the_buildings_of/", + "read": false, + "rule": 82, + "remote_identifier": "humet2" + } +}, +{ + "model": "core.post", + "pk": 3077, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-21T20:18:04.237Z", + "title": "Thank you CIG for updated heightmaps and render distances", + "body": "
      \"Thank
      ", + "author": "u7f76", + "publication_date": "2020-07-19T23:38:22Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hu8pwf/thank_you_cig_for_updated_heightmaps_and_render/", + "read": true, + "rule": 82, + "remote_identifier": "hu8pwf" + } +}, +{ + "model": "core.post", + "pk": 3078, + "fields": { + "created": "2020-07-20T19:32:35.562Z", + "modified": "2020-07-20T19:32:35.607Z", + "title": "This Week in Star Citizen | July 20th 2020", + "body": "", + "author": "ivtiprogamer", + "publication_date": "2020-07-20T19:50:29Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hupxnt/this_week_in_star_citizen_july_20th_2020/", + "read": false, + "rule": 82, + "remote_identifier": "hupxnt" + } +}, +{ + "model": "core.post", + "pk": 3079, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:34:36.068Z", + "title": "Bravo CIG lighting team! Noticeable improvements to all around environment lighting in 3.10", + "body": "
      \"Bravo
      ", + "author": "u7f76", + "publication_date": "2020-07-20T00:02:23Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hu94o0/bravo_cig_lighting_team_noticeable_improvements/", + "read": true, + "rule": 82, + "remote_identifier": "hu94o0" + } +}, +{ + "model": "core.post", + "pk": 3080, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:32:35.613Z", + "title": "Thick", + "body": "
      \"Thick\"
      ", + "author": "burgerbagel", + "publication_date": "2020-07-20T16:24:38Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hum50f/thick/", + "read": false, + "rule": 82, + "remote_identifier": "hum50f" + } +}, +{ + "model": "core.post", + "pk": 3081, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:34:19.763Z", + "title": "Soon\u2122", + "body": "
      \"Soon\u2122\"
      ", + "author": "Mistralette", + "publication_date": "2020-07-20T05:54:09Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hueg01/soon/", + "read": true, + "rule": 82, + "remote_identifier": "hueg01" + } +}, +{ + "model": "core.post", + "pk": 3082, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:32:35.618Z", + "title": "On the prowl", + "body": "
      \"On
      ", + "author": "SaraCaterina", + "publication_date": "2020-07-20T16:37:03Z", + "url": "https://www.reddit.com/r/starcitizen/comments/humcmb/on_the_prowl/", + "read": false, + "rule": 82, + "remote_identifier": "humcmb" + } +}, +{ + "model": "core.post", + "pk": 3083, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:34:07.272Z", + "title": "The Hills Have Eyes", + "body": "
      \"The
      ", + "author": "FallenLordik", + "publication_date": "2020-07-20T11:19:19Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hui8ao/the_hills_have_eyes/", + "read": true, + "rule": 82, + "remote_identifier": "hui8ao" + } +}, +{ + "model": "core.post", + "pk": 3084, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:32:35.623Z", + "title": "Worried about longer loading screens? Hit ~ and do r_displayinfo 3", + "body": "
      \"Worried
      ", + "author": "kristokn", + "publication_date": "2020-07-20T10:09:53Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huhif1/worried_about_longer_loading_screens_hit_and_do_r/", + "read": false, + "rule": 82, + "remote_identifier": "huhif1" + } +}, +{ + "model": "core.post", + "pk": 3085, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:32:35.625Z", + "title": "My contribution to the wallpaper contest... click for the full effect (3440x1440)", + "body": "
      \"My
      ", + "author": "Dougie_Juice", + "publication_date": "2020-07-20T20:02:31Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huq655/my_contribution_to_the_wallpaper_contest_click/", + "read": false, + "rule": 82, + "remote_identifier": "huq655" + } +}, +{ + "model": "core.post", + "pk": 3086, + "fields": { + "created": "2020-07-20T19:32:35.563Z", + "modified": "2020-07-20T19:32:35.627Z", + "title": "Star Citizen: The Onion (Parody Project)", + "body": "", + "author": "BroadOne", + "publication_date": "2020-07-20T19:19:20Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hupbkj/star_citizen_the_onion_parody_project/", + "read": false, + "rule": 82, + "remote_identifier": "hupbkj" + } +}, +{ + "model": "core.post", + "pk": 3087, + "fields": { + "created": "2020-07-20T19:32:35.635Z", + "modified": "2020-07-20T19:32:35.637Z", + "title": "perfect day to sunbathe", + "body": "
      ", + "author": "Pedrica1", + "publication_date": "2020-07-20T18:08:17Z", + "url": "https://www.reddit.com/r/aww/comments/hunysb/perfect_day_to_sunbathe/", + "read": false, + "rule": 81, + "remote_identifier": "hunysb" + } +}, +{ + "model": "core.post", + "pk": 3088, + "fields": { + "created": "2020-07-20T19:32:35.635Z", + "modified": "2020-07-20T19:32:35.639Z", + "title": "My dogs face when he sees I'm home", + "body": "
      ", + "author": "NewReddit_WhoDis", + "publication_date": "2020-07-20T16:45:21Z", + "url": "https://www.reddit.com/r/aww/comments/humhxa/my_dogs_face_when_he_sees_im_home/", + "read": false, + "rule": 81, + "remote_identifier": "humhxa" + } +}, +{ + "model": "core.post", + "pk": 3089, + "fields": { + "created": "2020-07-20T19:32:35.635Z", + "modified": "2020-07-20T19:32:35.641Z", + "title": "Cow loves the scritch machine", + "body": "
      ", + "author": "Der_Ist", + "publication_date": "2020-07-20T17:36:16Z", + "url": "https://www.reddit.com/r/aww/comments/hundvo/cow_loves_the_scritch_machine/", + "read": false, + "rule": 81, + "remote_identifier": "hundvo" + } +}, +{ + "model": "core.post", + "pk": 3090, + "fields": { + "created": "2020-07-20T19:32:35.635Z", + "modified": "2020-07-20T19:32:35.643Z", + "title": "Can I sit next to you ?", + "body": "
      ", + "author": "wheezy098", + "publication_date": "2020-07-20T17:55:10Z", + "url": "https://www.reddit.com/r/aww/comments/hunq5h/can_i_sit_next_to_you/", + "read": false, + "rule": 81, + "remote_identifier": "hunq5h" + } +}, +{ + "model": "core.post", + "pk": 3091, + "fields": { + "created": "2020-07-20T19:32:35.635Z", + "modified": "2020-07-20T19:32:35.645Z", + "title": "IS THAT A CUSTOMER? flop flop flop flop .... \" Can I uhh... help you sir?\"", + "body": "
      ", + "author": "MBMV", + "publication_date": "2020-07-20T12:50:40Z", + "url": "https://www.reddit.com/r/aww/comments/huj7g3/is_that_a_customer_flop_flop_flop_flop_can_i_uhh/", + "read": false, + "rule": 81, + "remote_identifier": "huj7g3" + } +}, +{ + "model": "core.post", + "pk": 3092, + "fields": { + "created": "2020-07-20T19:32:35.635Z", + "modified": "2020-07-20T19:32:35.647Z", + "title": "Good Boy turned Disney Princess", + "body": "
      ", + "author": "Sauwercraud", + "publication_date": "2020-07-20T18:40:05Z", + "url": "https://www.reddit.com/r/aww/comments/huojq0/good_boy_turned_disney_princess/", + "read": false, + "rule": 81, + "remote_identifier": "huojq0" + } +}, +{ + "model": "core.post", + "pk": 3093, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.649Z", + "title": "Kitty loop", + "body": "
      ", + "author": "Dlatrex", + "publication_date": "2020-07-20T12:54:02Z", + "url": "https://www.reddit.com/r/aww/comments/huj8s6/kitty_loop/", + "read": false, + "rule": 81, + "remote_identifier": "huj8s6" + } +}, +{ + "model": "core.post", + "pk": 3094, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.652Z", + "title": "if i fits i sits", + "body": "
      ", + "author": "jasontaken", + "publication_date": "2020-07-20T16:38:32Z", + "url": "https://www.reddit.com/r/aww/comments/humdlf/if_i_fits_i_sits/", + "read": false, + "rule": 81, + "remote_identifier": "humdlf" + } +}, +{ + "model": "core.post", + "pk": 3095, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.654Z", + "title": "Isn\u2019t she Adorable !", + "body": "
      \"Isn\u2019t
      ", + "author": "MunchyMac", + "publication_date": "2020-07-20T16:18:05Z", + "url": "https://www.reddit.com/r/aww/comments/hum133/isnt_she_adorable/", + "read": false, + "rule": 81, + "remote_identifier": "hum133" + } +}, +{ + "model": "core.post", + "pk": 3096, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.655Z", + "title": "Thank you mama (\u2283\uff61\u2022\u0301\u203f\u2022\u0300\uff61)\u2283", + "body": "
      ", + "author": "AnoushkaSingh", + "publication_date": "2020-07-20T13:35:51Z", + "url": "https://www.reddit.com/r/aww/comments/hujpxy/thank_you_mama/", + "read": false, + "rule": 81, + "remote_identifier": "hujpxy" + } +}, +{ + "model": "core.post", + "pk": 3097, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.657Z", + "title": "I WANT TO HUG HIM SO BAD!!!", + "body": "
      ", + "author": "BATMAN_5777", + "publication_date": "2020-07-20T18:25:20Z", + "url": "https://www.reddit.com/r/aww/comments/huo9z4/i_want_to_hug_him_so_bad/", + "read": false, + "rule": 81, + "remote_identifier": "huo9z4" + } +}, +{ + "model": "core.post", + "pk": 3098, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.659Z", + "title": "Before and after being called a good boy", + "body": "
      \"Before
      ", + "author": "vladgrinch", + "publication_date": "2020-07-20T10:48:40Z", + "url": "https://www.reddit.com/r/aww/comments/huhwu9/before_and_after_being_called_a_good_boy/", + "read": false, + "rule": 81, + "remote_identifier": "huhwu9" + } +}, +{ + "model": "core.post", + "pk": 3099, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.662Z", + "title": "My fianc\u00e9 has wanted a dog his whole life. This is his college graduation present. Welcome home Maple!", + "body": "
      \"My
      ", + "author": "AlexisaurusRex", + "publication_date": "2020-07-20T17:57:25Z", + "url": "https://www.reddit.com/r/aww/comments/hunrie/my_fianc\u00e9_has_wanted_a_dog_his_whole_life_this_is/", + "read": false, + "rule": 81, + "remote_identifier": "hunrie" + } +}, +{ + "model": "core.post", + "pk": 3100, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.664Z", + "title": "Cute burro.", + "body": "
      \"Cute
      ", + "author": "Craftmine101", + "publication_date": "2020-07-20T13:45:32Z", + "url": "https://www.reddit.com/r/aww/comments/huju40/cute_burro/", + "read": false, + "rule": 81, + "remote_identifier": "huju40" + } +}, +{ + "model": "core.post", + "pk": 3101, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.666Z", + "title": "I've never seen anyone dance better than that turtle.", + "body": "
      ", + "author": "Ashley1023", + "publication_date": "2020-07-20T18:07:30Z", + "url": "https://www.reddit.com/r/aww/comments/hunya8/ive_never_seen_anyone_dance_better_than_that/", + "read": false, + "rule": 81, + "remote_identifier": "hunya8" + } +}, +{ + "model": "core.post", + "pk": 3102, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.669Z", + "title": "Someone\u2019s going to be quite surprised when he realizes all this new stuff isn\u2019t for him!", + "body": "
      \"Someone\u2019s
      ", + "author": "molly590", + "publication_date": "2020-07-20T15:46:21Z", + "url": "https://www.reddit.com/r/aww/comments/hulikg/someones_going_to_be_quite_surprised_when_he/", + "read": false, + "rule": 81, + "remote_identifier": "hulikg" + } +}, +{ + "model": "core.post", + "pk": 3103, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.671Z", + "title": "my aunt asked me to paint her puppy and I think it turned out so cute!!!", + "body": "
      \"my
      ", + "author": "PineappleLightt", + "publication_date": "2020-07-20T16:39:37Z", + "url": "https://www.reddit.com/r/aww/comments/humea0/my_aunt_asked_me_to_paint_her_puppy_and_i_think/", + "read": false, + "rule": 81, + "remote_identifier": "humea0" + } +}, +{ + "model": "core.post", + "pk": 3104, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.673Z", + "title": "Master Assassin", + "body": "
      \"Master
      ", + "author": "LauWalker", + "publication_date": "2020-07-20T18:47:52Z", + "url": "https://www.reddit.com/r/aww/comments/huop8a/master_assassin/", + "read": false, + "rule": 81, + "remote_identifier": "huop8a" + } +}, +{ + "model": "core.post", + "pk": 3105, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.675Z", + "title": "Every time this tank cleaner cleans out the aquarium, this fish swims over to him looking for pets", + "body": "", + "author": "unnaturalorder", + "publication_date": "2020-07-20T05:29:30Z", + "url": "https://www.reddit.com/r/aww/comments/hue3r0/every_time_this_tank_cleaner_cleans_out_the/", + "read": false, + "rule": 81, + "remote_identifier": "hue3r0" + } +}, +{ + "model": "core.post", + "pk": 3106, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.678Z", + "title": "My girlfriend sent me this while I was at work. And here I was thinking the perfect picture of our dog didn't exist", + "body": "", + "author": "Khuma-zi_Eldrama", + "publication_date": "2020-07-20T19:22:48Z", + "url": "https://www.reddit.com/r/aww/comments/hupdz8/my_girlfriend_sent_me_this_while_i_was_at_work/", + "read": false, + "rule": 81, + "remote_identifier": "hupdz8" + } +}, +{ + "model": "core.post", + "pk": 3107, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.680Z", + "title": "My first ever post, everyone meet my new baby girl Kiora! I\u2019m so in love with her\ud83e\udd7a\ud83d\udcab", + "body": "
      \"My
      ", + "author": "Dumpling2463", + "publication_date": "2020-07-20T05:34:29Z", + "url": "https://www.reddit.com/r/aww/comments/hue6dx/my_first_ever_post_everyone_meet_my_new_baby_girl/", + "read": false, + "rule": 81, + "remote_identifier": "hue6dx" + } +}, +{ + "model": "core.post", + "pk": 3108, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.682Z", + "title": "Dog splashing in water", + "body": "", + "author": "TheRikari", + "publication_date": "2020-07-20T15:44:02Z", + "url": "https://www.reddit.com/r/aww/comments/hulh8k/dog_splashing_in_water/", + "read": false, + "rule": 81, + "remote_identifier": "hulh8k" + } +}, +{ + "model": "core.post", + "pk": 3109, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.685Z", + "title": "They say taking breaks is the key to productivity!", + "body": "
      ", + "author": "Thereaper29", + "publication_date": "2020-07-20T05:43:40Z", + "url": "https://www.reddit.com/r/aww/comments/hueawt/they_say_taking_breaks_is_the_key_to_productivity/", + "read": false, + "rule": 81, + "remote_identifier": "hueawt" + } +}, +{ + "model": "core.post", + "pk": 3110, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.687Z", + "title": "I went away for 3 weeks, and now my cat is in love with my husband", + "body": "
      \"I
      ", + "author": "sillykittyish", + "publication_date": "2020-07-20T03:29:11Z", + "url": "https://www.reddit.com/r/aww/comments/hucd7u/i_went_away_for_3_weeks_and_now_my_cat_is_in_love/", + "read": false, + "rule": 81, + "remote_identifier": "hucd7u" + } +}, +{ + "model": "core.post", + "pk": 3111, + "fields": { + "created": "2020-07-20T19:32:35.636Z", + "modified": "2020-07-20T19:32:35.689Z", + "title": "Can you feel the love", + "body": "
      ", + "author": "kettySewrdPic", + "publication_date": "2020-07-20T09:13:32Z", + "url": "https://www.reddit.com/r/aww/comments/hugx1k/can_you_feel_the_love/", + "read": false, + "rule": 81, + "remote_identifier": "hugx1k" + } +}, +{ + "model": "core.post", + "pk": 3112, + "fields": { + "created": "2020-07-20T19:32:35.835Z", + "modified": "2020-07-21T20:14:50.522Z", + "title": "Linux Experiences/Rants or Education/Certifications thread - July 20, 2020", + "body": "

      Welcome to r/linux rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.

      \n\n

      Let us know what's annoying you, whats making you happy, or something that you want to get out to r/linux but didn't make the cut into a full post of it's own.

      \n\n

      For those looking for certifications please use this megathread to ask about how to get certified whether it's for the business world or for your own satisfaction. Be sure to check out r/linuxadmin for more discussion in the SysAdmin world!

      \n\n

      Please keep questions in r/linuxquestions, r/linux4noobs, or the Wednesday automod thread.

      \n
      ", + "author": "AutoModerator", + "publication_date": "2020-07-20T06:12:00Z", + "url": "https://www.reddit.com/r/linux/comments/hueoo0/linux_experiencesrants_or_educationcertifications/", + "read": false, + "rule": 80, + "remote_identifier": "hueoo0" + } +}, +{ + "model": "core.post", + "pk": 3113, + "fields": { + "created": "2020-07-20T19:32:35.836Z", + "modified": "2020-07-21T20:19:49.339Z", + "title": "Unix Family Tree", + "body": "
      \"Unix
      ", + "author": "bauripalash", + "publication_date": "2020-07-20T10:32:15Z", + "url": "https://www.reddit.com/r/linux/comments/huhqrh/unix_family_tree/", + "read": true, + "rule": 80, + "remote_identifier": "huhqrh" + } +}, +{ + "model": "core.post", + "pk": 3114, + "fields": { + "created": "2020-07-20T19:32:35.836Z", + "modified": "2020-07-21T20:14:50.554Z", + "title": "NVIDIA open sourced part of NVAPI SDK to aid 'Windows emulation environments'", + "body": "", + "author": "ignapk", + "publication_date": "2020-07-20T13:17:19Z", + "url": "https://www.reddit.com/r/linux/comments/huji8c/nvidia_open_sourced_part_of_nvapi_sdk_to_aid/", + "read": false, + "rule": 80, + "remote_identifier": "huji8c" + } +}, +{ + "model": "core.post", + "pk": 3115, + "fields": { + "created": "2020-07-20T19:32:35.836Z", + "modified": "2020-07-21T20:14:50.551Z", + "title": "Jellyfin 10.6 released", + "body": "", + "author": "resoluti0n_", + "publication_date": "2020-07-20T16:40:05Z", + "url": "https://www.reddit.com/r/linux/comments/humekr/jellyfin_106_released/", + "read": false, + "rule": 80, + "remote_identifier": "humekr" + } +}, +{ + "model": "core.post", + "pk": 3116, + "fields": { + "created": "2020-07-20T19:32:35.836Z", + "modified": "2020-07-21T20:14:50.583Z", + "title": "[German] Article in major german newspaper about trying Linux and WSL. Literal: \"Why it's beneficial to try Linux now\"", + "body": "", + "author": "noname7890", + "publication_date": "2020-07-19T15:19:27Z", + "url": "https://www.reddit.com/r/linux/comments/hu0d5v/german_article_in_major_german_newspaper_about/", + "read": false, + "rule": 80, + "remote_identifier": "hu0d5v" + } +}, +{ + "model": "core.post", + "pk": 3117, + "fields": { + "created": "2020-07-20T19:32:35.837Z", + "modified": "2020-07-21T20:14:50.574Z", + "title": "Brian Kernighan: UNIX, C, AWK, AMPL, and Go Programming | AI Podcast #109 with Lex Fridman", + "body": "", + "author": "tinyatom", + "publication_date": "2020-07-20T08:48:35Z", + "url": "https://www.reddit.com/r/linux/comments/hugn0w/brian_kernighan_unix_c_awk_ampl_and_go/", + "read": false, + "rule": 80, + "remote_identifier": "hugn0w" + } +}, +{ + "model": "core.post", + "pk": 3118, + "fields": { + "created": "2020-07-20T19:32:35.837Z", + "modified": "2020-07-21T20:14:50.578Z", + "title": "Explaining Computers Host Christopher Barnatt Has Switched To Linux", + "body": "", + "author": "sysrpl", + "publication_date": "2020-07-20T13:00:02Z", + "url": "https://www.reddit.com/r/linux/comments/hujb12/explaining_computers_host_christopher_barnatt_has/", + "read": false, + "rule": 80, + "remote_identifier": "hujb12" + } +}, +{ + "model": "core.post", + "pk": 3119, + "fields": { + "created": "2020-07-20T19:32:35.837Z", + "modified": "2020-07-21T20:14:50.529Z", + "title": "Ireland donates contact tracing app to the Linux foundation.", + "body": "", + "author": "mathiasryan", + "publication_date": "2020-07-20T21:31:43Z", + "url": "https://www.reddit.com/r/linux/comments/hury4e/ireland_donates_contact_tracing_app_to_the_linux/", + "read": false, + "rule": 80, + "remote_identifier": "hury4e" + } +}, +{ + "model": "core.post", + "pk": 3120, + "fields": { + "created": "2020-07-20T19:32:35.842Z", + "modified": "2020-07-21T20:14:50.588Z", + "title": "I implemented a simple terminal-based password manager", + "body": "

      I created a simple, secure, and free password manager written in C: SaltPass. I haven't contributed open source code before, but I think this might be useful to a few people. Especially as an alternative to paid solutions such as LastPass and the likes. Any suggestions/edits/code improvements would be greatly appreciated!

      \n
      ", + "author": "zaid-gg", + "publication_date": "2020-07-20T07:43:03Z", + "url": "https://www.reddit.com/r/linux/comments/hufula/i_implemented_a_simple_terminalbased_password/", + "read": false, + "rule": 80, + "remote_identifier": "hufula" + } +}, +{ + "model": "core.post", + "pk": 3121, + "fields": { + "created": "2020-07-20T19:32:35.843Z", + "modified": "2020-07-21T20:14:50.593Z", + "title": "Performance analysis of multi services on container Docker, LXC, and LXD - Bulletin of Electrical Engineering and Informatics, Adinda Riztia Putri, Rendy Munadi, Ridha Muldina Negara Adaptive Network\u2026", + "body": "", + "author": "bmullan", + "publication_date": "2020-07-20T11:35:59Z", + "url": "https://www.reddit.com/r/linux/comments/huieio/performance_analysis_of_multi_services_on/", + "read": false, + "rule": 80, + "remote_identifier": "huieio" + } +}, +{ + "model": "core.post", + "pk": 3122, + "fields": { + "created": "2020-07-20T19:32:35.844Z", + "modified": "2020-07-21T20:14:50.602Z", + "title": "Create an Internal PKI using OpenSSL and NitroKey HSM", + "body": "", + "author": "PixelPaulaus", + "publication_date": "2020-07-20T06:18:41Z", + "url": "https://www.reddit.com/r/linux/comments/huerpn/create_an_internal_pki_using_openssl_and_nitrokey/", + "read": false, + "rule": 80, + "remote_identifier": "huerpn" + } +}, +{ + "model": "core.post", + "pk": 3123, + "fields": { + "created": "2020-07-20T19:32:35.844Z", + "modified": "2020-07-20T19:32:35.883Z", + "title": "vopono - run applications via VPNs with temporary network namespaces", + "body": "", + "author": "nivenkos", + "publication_date": "2020-07-19T20:02:57Z", + "url": "https://www.reddit.com/r/linux/comments/hu4vge/vopono_run_applications_via_vpns_with_temporary/", + "read": false, + "rule": 80, + "remote_identifier": "hu4vge" + } +}, +{ + "model": "core.post", + "pk": 3124, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.886Z", + "title": "Double (triple, quadruple...) internet speed with openvpn tap channel bonding to a linux VPS", + "body": "

      I have been working a couple of days on my latest video about channel bonding - the video is heavily inspired be this article on Serverfault. In essence, I have been searching for a while on how to bond multiple VPN channels together in order to increase internet speed - there does not seem to be a lot of information around - mainly articles on forums and reddit state that it should be possible but a detailed guide is hard to find. I am using two Ubuntu machines in order to build the connection - one local and one VPS. The bash scripts I use in my video in order to achieve tap channel bonding are available on my github repository. I am currently working on a second video in order to walk through and explain the scripts in depth. Enjoy!

      \n\n

      (EDIT) - the question has come up in the discussions below if this is really packet load balancing or rather balancing links only - please see my comment further down - I can confirm that this DOES packet balancing so it does work as described.

      \n
      ", + "author": "onemarcfifty", + "publication_date": "2020-07-19T20:41:40Z", + "url": "https://www.reddit.com/r/linux/comments/hu5l4f/double_triple_quadruple_internet_speed_with/", + "read": false, + "rule": 80, + "remote_identifier": "hu5l4f" + } +}, +{ + "model": "core.post", + "pk": 3125, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.888Z", + "title": "OpenRGB - Open source RGB lighting control that doesn't depend on manufacturer software, supports Linux", + "body": "", + "author": "pr0_c0d3", + "publication_date": "2020-07-18T16:52:48Z", + "url": "https://www.reddit.com/r/linux/comments/hthuli/openrgb_open_source_rgb_lighting_control_that/", + "read": false, + "rule": 80, + "remote_identifier": "hthuli" + } +}, +{ + "model": "core.post", + "pk": 3126, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.890Z", + "title": "Make this any sense? Automatic CPU Speed & Power Optimizer", + "body": "", + "author": "spite77", + "publication_date": "2020-07-20T11:53:35Z", + "url": "https://www.reddit.com/r/linux/comments/huikxz/make_this_any_sense_automatic_cpu_speed_power/", + "read": false, + "rule": 80, + "remote_identifier": "huikxz" + } +}, +{ + "model": "core.post", + "pk": 3127, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.891Z", + "title": "Let\u2019s not be pedantic about \u201cOpen Source\u201d", + "body": "", + "author": "speckz", + "publication_date": "2020-07-20T16:46:43Z", + "url": "https://www.reddit.com/r/linux/comments/humirw/lets_not_be_pedantic_about_open_source/", + "read": false, + "rule": 80, + "remote_identifier": "humirw" + } +}, +{ + "model": "core.post", + "pk": 3128, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.893Z", + "title": "Experiences with running Linux Lite", + "body": "", + "author": "daemonpenguin", + "publication_date": "2020-07-20T02:43:49Z", + "url": "https://www.reddit.com/r/linux/comments/hubonw/experiences_with_running_linux_lite/", + "read": false, + "rule": 80, + "remote_identifier": "hubonw" + } +}, +{ + "model": "core.post", + "pk": 3129, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.895Z", + "title": "Tried gnome on arch, surprised how lean it is (used flameshot so it used about 72mb more) closing at 600 megs) on fedora and pop i had gnome eating up 1.3gigs at boot up.", + "body": "
      \"Tried
      ", + "author": "V1n0dKr1shna", + "publication_date": "2020-07-18T13:54:55Z", + "url": "https://www.reddit.com/r/linux/comments/htfeph/tried_gnome_on_arch_surprised_how_lean_it_is_used/", + "read": false, + "rule": 80, + "remote_identifier": "htfeph" + } +}, +{ + "model": "core.post", + "pk": 3130, + "fields": { + "created": "2020-07-20T19:32:35.849Z", + "modified": "2020-07-20T19:32:35.897Z", + "title": "The Free Software Foundation is holding a Fundraiser, help them reach 200 members", + "body": "", + "author": "Neet-Feet", + "publication_date": "2020-07-18T17:55:30Z", + "url": "https://www.reddit.com/r/linux/comments/htiuyi/the_free_software_foundation_is_holding_a/", + "read": false, + "rule": 80, + "remote_identifier": "htiuyi" + } +}, +{ + "model": "core.post", + "pk": 3131, + "fields": { + "created": "2020-07-20T19:32:35.853Z", + "modified": "2020-07-20T19:32:35.899Z", + "title": "Why is the mindset around Arch so negative?", + "body": "

      I love the Linux community as a whole. You can find some of the most creative and imaginative people within most Linux communities. On a whole, Linux users are some of the most helpful and informative people you can encounter. Truly the type to think outside the box and learn new things. It can be very inspirational.

      \n\n

      If I jumped onto Ubuntu, Fedora, or openSUSE's community I can have a free flowing conversation about Linux, their distribution, and getting help or giving help is so free-flowing and easy. The communities are eager to welcome new people and appreciate folks who contribute.

      \n\n

      Then you have Arch. I love the OS but dislike the mindset. Asking for help is meat with resistance, giving help can also be punishable, and god forbid you try to have a discussion. But it's not just their core community either. For example, I just discovered Endeavour OS which is built around Arch and after 11 post I'm told to come back in 8 hours. Their subReddit here on Reddit, you have to ask to even make 1 post. There of course is also Manjaro Linux and they too have this gatekeeper mindset, the same can be said for ArcoLinux.

      \n\n

      What is it about Arch that makes everyone want to be either a control freak or a gatekeeper?

      \n\n

      I do not see this within the Ubuntu or Fedora or openSUSE communities. As I said, their mindset seems eager and willing to unite and work as a community. Am I the only how has noticed this?

      \n
      ", + "author": "Linux-Is-Best", + "publication_date": "2020-07-18T23:28:12Z", + "url": "https://www.reddit.com/r/linux/comments/htojwk/why_is_the_mindset_around_arch_so_negative/", + "read": false, + "rule": 80, + "remote_identifier": "htojwk" + } +}, +{ + "model": "core.post", + "pk": 3132, + "fields": { + "created": "2020-07-20T19:32:35.853Z", + "modified": "2020-07-20T19:32:35.901Z", + "title": "Using the nstat network statistics command in Linux", + "body": "", + "author": "cronos426", + "publication_date": "2020-07-19T17:55:55Z", + "url": "https://www.reddit.com/r/linux/comments/hu2q6v/using_the_nstat_network_statistics_command_in/", + "read": false, + "rule": 80, + "remote_identifier": "hu2q6v" + } +}, +{ + "model": "core.post", + "pk": 3133, + "fields": { + "created": "2020-07-20T19:32:35.853Z", + "modified": "2020-07-20T19:32:35.903Z", + "title": "Contributing via GitLab Merge Requests", + "body": "", + "author": "ChristophCullmann", + "publication_date": "2020-07-18T20:01:26Z", + "url": "https://www.reddit.com/r/linux/comments/htl05p/contributing_via_gitlab_merge_requests/", + "read": false, + "rule": 80, + "remote_identifier": "htl05p" + } +}, +{ + "model": "core.post", + "pk": 3134, + "fields": { + "created": "2020-07-20T19:32:35.853Z", + "modified": "2020-07-20T19:32:35.905Z", + "title": "OpenMandriva: combines WINE64 and 32 into one package capable of running both binaries, i686 architecture was considered as deprecated. Work is underway on a new Rolling release", + "body": "", + "author": "DamonsLinux", + "publication_date": "2020-07-18T15:02:35Z", + "url": "https://www.reddit.com/r/linux/comments/htg9dj/openmandriva_combines_wine64_and_32_into_one/", + "read": false, + "rule": 80, + "remote_identifier": "htg9dj" + } +}, +{ + "model": "core.post", + "pk": 3135, + "fields": { + "created": "2020-07-20T19:32:35.853Z", + "modified": "2020-07-20T19:32:35.906Z", + "title": "OpenRCT2 Player Survey 2020 - Previous survey shows almost 25% players are linux, please help represent linux in the most recent survey", + "body": "", + "author": "christophski", + "publication_date": "2020-07-18T11:39:06Z", + "url": "https://www.reddit.com/r/linux/comments/htdzuh/openrct2_player_survey_2020_previous_survey_shows/", + "read": false, + "rule": 80, + "remote_identifier": "htdzuh" + } +}, +{ + "model": "core.post", + "pk": 3136, + "fields": { + "created": "2020-07-20T19:32:35.853Z", + "modified": "2020-07-20T19:32:35.908Z", + "title": "This week in KDE: Get New Stuff fixes and more", + "body": "", + "author": "kyentei", + "publication_date": "2020-07-18T10:03:46Z", + "url": "https://www.reddit.com/r/linux/comments/htd1an/this_week_in_kde_get_new_stuff_fixes_and_more/", + "read": false, + "rule": 80, + "remote_identifier": "htd1an" + } +}, +{ + "model": "core.post", + "pk": 3137, + "fields": { + "created": "2020-07-20T19:32:35.857Z", + "modified": "2020-07-20T19:32:35.910Z", + "title": "Blender Runs on Linux Pinephone", + "body": "

      I managed to get the desktop version of Blender on the Pinephone, and it works really well except for a few bugs.

      \n\n

      See my post on r/blender:

      \n\n

      https://www.reddit.com/r/blender/comments/hsxv27/i_installed_blender_on_a_phone/

      \n\n

      and r/PINE64official:

      \n\n

      https://www.reddit.com/r/PINE64official/comments/hsxc33/blender_on_pine_phone_almost_usable/

      \n\n

      I've tried other desktop programs like Xournal and PPSSPP, their UIs also work well, I'd be able to do even more if OpenGL 3 was working.

      \n
      ", + "author": "InfiniteHawk", + "publication_date": "2020-07-17T22:35:14Z", + "url": "https://www.reddit.com/r/linux/comments/ht3d4k/blender_runs_on_linux_pinephone/", + "read": false, + "rule": 80, + "remote_identifier": "ht3d4k" + } +}, +{ + "model": "core.post", + "pk": 3138, + "fields": { + "created": "2020-07-21T20:14:50.415Z", + "modified": "2020-07-21T20:18:21.616Z", + "title": "Hrmmm They Need to Fix Throttle Animations in the Sabre", + "body": "
      ", + "author": "TheBootRanger", + "publication_date": "2020-07-21T13:26:01Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv5omc/hrmmm_they_need_to_fix_throttle_animations_in_the/", + "read": true, + "rule": 82, + "remote_identifier": "hv5omc" + } +}, +{ + "model": "core.post", + "pk": 3139, + "fields": { + "created": "2020-07-21T20:14:50.415Z", + "modified": "2020-07-21T20:18:49.999Z", + "title": "My first 3.10 landing could have gone better...", + "body": "
      ", + "author": "KnLfey", + "publication_date": "2020-07-21T16:04:50Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv7w85/my_first_310_landing_could_have_gone_better/", + "read": true, + "rule": 82, + "remote_identifier": "hv7w85" + } +}, +{ + "model": "core.post", + "pk": 3140, + "fields": { + "created": "2020-07-21T20:14:50.415Z", + "modified": "2020-07-21T20:14:50.439Z", + "title": "How about the Christmas in 3 more years?", + "body": "
      \"How
      ", + "author": "SpleanEater", + "publication_date": "2020-07-21T17:49:22Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv9qy8/how_about_the_christmas_in_3_more_years/", + "read": false, + "rule": 82, + "remote_identifier": "hv9qy8" + } +}, +{ + "model": "core.post", + "pk": 3141, + "fields": { + "created": "2020-07-21T20:14:50.415Z", + "modified": "2020-07-21T20:18:33.532Z", + "title": "Long time Elite Dangerous player. New to star citizen i think im doing great", + "body": "", + "author": "Filblo5", + "publication_date": "2020-07-21T15:33:49Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv7elb/long_time_elite_dangerous_player_new_to_star/", + "read": true, + "rule": 82, + "remote_identifier": "hv7elb" + } +}, +{ + "model": "core.post", + "pk": 3142, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:14:50.443Z", + "title": "And we stand by it.", + "body": "
      \"And
      ", + "author": "CyberTill", + "publication_date": "2020-07-21T18:57:48Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvb3wm/and_we_stand_by_it/", + "read": false, + "rule": 82, + "remote_identifier": "hvb3wm" + } +}, +{ + "model": "core.post", + "pk": 3143, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:14:50.446Z", + "title": "Nomad", + "body": "
      \"Nomad\"
      ", + "author": "ibracitizen", + "publication_date": "2020-07-21T19:52:24Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvc5h3/nomad/", + "read": false, + "rule": 82, + "remote_identifier": "hvc5h3" + } +}, +{ + "model": "core.post", + "pk": 3144, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:14:50.449Z", + "title": "Probably the best screen cap i've ever caught on a whim. 3.5 Arc Corp release. Also a confession: I never pledged. Got a ship with my GPU. I intend to pay my dues.", + "body": "
      \"Probably
      ", + "author": "ScionoicS", + "publication_date": "2020-07-21T20:23:01Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvcqzf/probably_the_best_screen_cap_ive_ever_caught_on_a/", + "read": false, + "rule": 82, + "remote_identifier": "hvcqzf" + } +}, +{ + "model": "core.post", + "pk": 3145, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:14:50.451Z", + "title": "Play to escape the depressing job hunt where I need 10 years experience for a entry level job to find this, only been playing for 1 and a half years :(", + "body": "
      \"Play
      ", + "author": "Albert-III-", + "publication_date": "2020-07-21T12:23:45Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv4z08/play_to_escape_the_depressing_job_hunt_where_i/", + "read": false, + "rule": 82, + "remote_identifier": "hv4z08" + } +}, +{ + "model": "core.post", + "pk": 3146, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:19:00.691Z", + "title": "The void beckons.", + "body": "
      ", + "author": "HisNameWasHis", + "publication_date": "2020-07-21T14:40:51Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv6nij/the_void_beckons/", + "read": true, + "rule": 82, + "remote_identifier": "hv6nij" + } +}, +{ + "model": "core.post", + "pk": 3147, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:19:05.881Z", + "title": "I made a SC-like Photobash with Soldiers", + "body": "
      \"I
      ", + "author": "IsaacPolar", + "publication_date": "2020-07-21T17:13:39Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv92ri/i_made_a_sclike_photobash_with_soldiers/", + "read": true, + "rule": 82, + "remote_identifier": "hv92ri" + } +}, +{ + "model": "core.post", + "pk": 3148, + "fields": { + "created": "2020-07-21T20:14:50.416Z", + "modified": "2020-07-21T20:19:41.227Z", + "title": "Ocean Shader Improvements", + "body": "
      \"Ocean
      ", + "author": "shoeii", + "publication_date": "2020-07-21T18:41:51Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvasds/ocean_shader_improvements/", + "read": true, + "rule": 82, + "remote_identifier": "hvasds" + } +}, +{ + "model": "core.post", + "pk": 3149, + "fields": { + "created": "2020-07-21T20:14:50.420Z", + "modified": "2020-07-21T20:14:50.459Z", + "title": "As much shit as Star Citizen (rightfully) gets it still does one thing better than any other 'game' I've played", + "body": "

      It invokes a real sense of scale, on multiple levels.

      \n\n

      One could argue that's one of the most important feelings you'd want to capture in any game set in space, but of course it's mostly meaningless if there aren't enough gameplay loops and systems in place to work in tandem with and make the space that's been created interesting, and that's where SC is currently a failure.

      \n\n

      Even so, I think being able to create that sense of smallness isn't insignificant.

      \n\n

      You as a pilot are dwarfed by your ship which is itself dwarfed by a larger ship which is itself dwarfed by another, even more massive one which is dwarfed by the space station or hub you're at which is dwarfed by a crater on a moon which is dwarfed by the moon itself which is dwarfed by the planet it orbits which is dwarfed by the sheer vastness of space in between all of those things and that they are, despite the distance, still connected.

      \n\n

      Getting lost in Lorville (even if it is mostly linear) and knowing it's only a small part of the playable space is a really neat feeling - looking out from the windows of the train up into the sky and knowing you can go there and beyond really makes you feel like there is a whole world (and more) waiting to be explored.

      \n\n

      I think this is a direct result of having legs and not being locked into the cockpit of your ship - I've played more Elite: Dangerous than Star Citizen and it accomplishes a similar sense of scale but, at least not as far as I've felt, never to the same degree - because you're locked in your ship you never really get this same sense of being small or insignificant even though you are dwarfed in similar ways by planets/asteroids/other ships - will be interesting to see how their implementation of 'space legs' in the upcoming expansion changes this.

      \n\n

      My favourite thing to do in Star Citizen (because there isn't a whole lot) is to just find some pocket of space far away from anything else and just walk around my ship, feeling truly alone and insignificant, gazing out at the void that stretches infinitely all around - something about that is super comfy.

      \n\n

      I can't think of many other game that accomplish a similar level of scale though I'm sure they exist.

      \n\n

      I've been playing an indie game called Empyrion - Galactic Survival and it actually is sort of similar to SC in this regard but it's nowhere near as polished or smooth - transitions from atmosphere to space are not truly seamless and planets themselves are kind of stitched together, but it still manages to invoke that same kind of awe at the scale of things when you dock a small vessel to a capital vessel, for example - definitely worth checking out if you like sci-fi/space games, which you must if you're here, but just be prepared for the jank.

      \n
      ", + "author": "thegreatself", + "publication_date": "2020-07-21T20:30:15Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvcw38/as_much_shit_as_star_citizen_rightfully_gets_it/", + "read": false, + "rule": 82, + "remote_identifier": "hvcw38" + } +}, +{ + "model": "core.post", + "pk": 3150, + "fields": { + "created": "2020-07-21T20:14:50.420Z", + "modified": "2020-07-21T20:14:50.462Z", + "title": "You waiting for patch 3.10 to go live while watching tons of videos about the new flight model features. Be patient, 3.11 and 3.12 will be even better.", + "body": "
      \"You
      ", + "author": "jsabater76", + "publication_date": "2020-07-21T09:39:27Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv372v/you_waiting_for_patch_310_to_go_live_while/", + "read": false, + "rule": 82, + "remote_identifier": "hv372v" + } +}, +{ + "model": "core.post", + "pk": 3151, + "fields": { + "created": "2020-07-21T20:14:50.420Z", + "modified": "2020-07-21T20:14:50.466Z", + "title": "CIG, can we please fix these \"black hole\" doors(when they are closed) on ships please.", + "body": "
      \"CIG,
      ", + "author": "AbnormallyBendPenis", + "publication_date": "2020-07-21T13:40:14Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv5uzj/cig_can_we_please_fix_these_black_hole_doorswhen/", + "read": false, + "rule": 82, + "remote_identifier": "hv5uzj" + } +}, +{ + "model": "core.post", + "pk": 3152, + "fields": { + "created": "2020-07-21T20:14:50.420Z", + "modified": "2020-07-21T20:14:50.468Z", + "title": "Anvil Super Hornet over Cellin", + "body": "
      \"Anvil
      ", + "author": "SaraCaterina", + "publication_date": "2020-07-21T20:33:58Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvcyq6/anvil_super_hornet_over_cellin/", + "read": false, + "rule": 82, + "remote_identifier": "hvcyq6" + } +}, +{ + "model": "core.post", + "pk": 3153, + "fields": { + "created": "2020-07-21T20:14:50.420Z", + "modified": "2020-07-21T20:14:50.471Z", + "title": "3.10 Combat Changes", + "body": "", + "author": "STLYoungblood", + "publication_date": "2020-07-21T16:37:44Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv8fr7/310_combat_changes/", + "read": false, + "rule": 82, + "remote_identifier": "hv8fr7" + } +}, +{ + "model": "core.post", + "pk": 3154, + "fields": { + "created": "2020-07-21T20:14:50.420Z", + "modified": "2020-07-21T20:14:50.472Z", + "title": "Hey CIG how about that S42 Vi.... Oh...", + "body": "
      \"Hey
      ", + "author": "SiEDeN", + "publication_date": "2020-07-21T21:37:16Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hve6am/hey_cig_how_about_that_s42_vi_oh/", + "read": false, + "rule": 82, + "remote_identifier": "hve6am" + } +}, +{ + "model": "core.post", + "pk": 3155, + "fields": { + "created": "2020-07-21T20:14:50.422Z", + "modified": "2020-07-21T20:14:50.475Z", + "title": "3.10 M PTU Eclipse improvements", + "body": "

      If this goes live, CIG had addressed 2 of my Eclipse critics.

      \n\n

      Not because of my videos of course, CIG doesn't know I exist.

      \n\n

       

      \n\n

      a. Eclipse has armor stealth in 3.10, see my table:\nhttps://docs.google.com/spreadsheets/d/1OJXg7MQsG_IVTPsmlmZYaxEPK4n4iqnhQx4oigIlJHg/edit#gid=343807746

      \n\n

       

      \n\n

      b. Eclipse can fire her size 9 torpedoes way quicker now, see my video with a side by side comparison of the max firing speed in 3.9 and 3.10:\nhttps://youtu.be/GFTF1Qt7T3o?t=207

      \n
      ", + "author": "Camural", + "publication_date": "2020-07-21T18:15:50Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hva9lc/310_m_ptu_eclipse_improvements/", + "read": false, + "rule": 82, + "remote_identifier": "hva9lc" + } +}, +{ + "model": "core.post", + "pk": 3156, + "fields": { + "created": "2020-07-21T20:14:50.422Z", + "modified": "2020-07-21T20:14:50.477Z", + "title": "Hark! The Drake Herald Sings", + "body": "
      \"Hark!
      ", + "author": "CyrexStorm", + "publication_date": "2020-07-21T16:19:31Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv84kk/hark_the_drake_herald_sings/", + "read": false, + "rule": 82, + "remote_identifier": "hv84kk" + } +}, +{ + "model": "core.post", + "pk": 3157, + "fields": { + "created": "2020-07-21T20:14:50.422Z", + "modified": "2020-07-21T20:14:50.479Z", + "title": "The new flight stick in the Prowler", + "body": "
      \"The
      ", + "author": "Potato_Nades", + "publication_date": "2020-07-21T16:22:22Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv86c2/the_new_flight_stick_in_the_prowler/", + "read": false, + "rule": 82, + "remote_identifier": "hv86c2" + } +}, +{ + "model": "core.post", + "pk": 3158, + "fields": { + "created": "2020-07-21T20:14:50.422Z", + "modified": "2020-07-21T20:14:50.481Z", + "title": "Norwegian VAT charged from August 1st", + "body": "
      \"Norwegian
      ", + "author": "norgeek", + "publication_date": "2020-07-21T10:30:57Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv3r3l/norwegian_vat_charged_from_august_1st/", + "read": false, + "rule": 82, + "remote_identifier": "hv3r3l" + } +}, +{ + "model": "core.post", + "pk": 3159, + "fields": { + "created": "2020-07-21T20:14:50.423Z", + "modified": "2020-07-21T20:14:50.484Z", + "title": "With Pyro (currently WIP), Nyx (partially done), Odin (S42), currently on the way, what is everyone\u2019s thoughts on Terra possibly being next on the list of star systems to be added into the PU within \u2026", + "body": "
      \"With
      ", + "author": "realCLTotaku", + "publication_date": "2020-07-21T13:27:09Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hv5p41/with_pyro_currently_wip_nyx_partially_done_odin/", + "read": false, + "rule": 82, + "remote_identifier": "hv5p41" + } +}, +{ + "model": "core.post", + "pk": 3160, + "fields": { + "created": "2020-07-21T20:14:50.423Z", + "modified": "2020-07-21T20:14:50.486Z", + "title": "Testing out the new electron rifle", + "body": "
      ", + "author": "joshbaker2112", + "publication_date": "2020-07-21T02:56:19Z", + "url": "https://www.reddit.com/r/starcitizen/comments/huxr6d/testing_out_the_new_electron_rifle/", + "read": false, + "rule": 82, + "remote_identifier": "huxr6d" + } +}, +{ + "model": "core.post", + "pk": 3161, + "fields": { + "created": "2020-07-21T20:14:50.423Z", + "modified": "2020-07-21T20:14:50.487Z", + "title": "Imperial Geographic's Lovecraftian magazine special is here. \ud83d\udc19 Find the link in the comments!", + "body": "
      \"Imperial
      ", + "author": "Good_Punk2", + "publication_date": "2020-07-21T18:21:38Z", + "url": "https://www.reddit.com/r/starcitizen/comments/hvadrh/imperial_geographics_lovecraftian_magazine/", + "read": false, + "rule": 82, + "remote_identifier": "hvadrh" + } +}, +{ + "model": "core.post", + "pk": 3162, + "fields": { + "created": "2020-07-21T20:14:50.497Z", + "modified": "2020-07-21T20:14:50.525Z", + "title": "Linux Distributions Timeline", + "body": "
      \"Linux
      ", + "author": "bauripalash", + "publication_date": "2020-07-21T06:07:59Z", + "url": "https://www.reddit.com/r/linux/comments/hv0ktn/linux_distributions_timeline/", + "read": false, + "rule": 80, + "remote_identifier": "hv0ktn" + } +}, +{ + "model": "core.post", + "pk": 3163, + "fields": { + "created": "2020-07-21T20:14:50.497Z", + "modified": "2020-07-21T20:14:50.527Z", + "title": "Fedora: Proposal to replace default wined3d backend with DXVK", + "body": "", + "author": "friskfrugt", + "publication_date": "2020-07-21T19:42:49Z", + "url": "https://www.reddit.com/r/linux/comments/hvbyyr/fedora_proposal_to_replace_default_wined3d/", + "read": false, + "rule": 80, + "remote_identifier": "hvbyyr" + } +}, +{ + "model": "core.post", + "pk": 3164, + "fields": { + "created": "2020-07-21T20:14:50.497Z", + "modified": "2020-07-21T20:14:50.531Z", + "title": "Update on marketing and communication plans for the LibreOffice 7.x series", + "body": "", + "author": "TheQuantumZero", + "publication_date": "2020-07-21T09:59:23Z", + "url": "https://www.reddit.com/r/linux/comments/hv3erm/update_on_marketing_and_communication_plans_for/", + "read": false, + "rule": 80, + "remote_identifier": "hv3erm" + } +}, +{ + "model": "core.post", + "pk": 3165, + "fields": { + "created": "2020-07-21T20:14:50.497Z", + "modified": "2020-07-21T20:14:50.533Z", + "title": "FOSS job opening: LibreOffice Development Mentor at The Document Foundation", + "body": "", + "author": "themikeosguy", + "publication_date": "2020-07-21T14:26:36Z", + "url": "https://www.reddit.com/r/linux/comments/hv6gfw/foss_job_opening_libreoffice_development_mentor/", + "read": false, + "rule": 80, + "remote_identifier": "hv6gfw" + } +}, +{ + "model": "core.post", + "pk": 3166, + "fields": { + "created": "2020-07-21T20:14:50.503Z", + "modified": "2020-07-21T20:14:50.536Z", + "title": "gomd - quickly display formatted markdown files with code highlight in your browser", + "body": "

      Hi all!

      \n\n

      I wanted to share a project I've been working on recently. I think it reached a stage where it's pretty usable and should work out of the box. gomd sets up a HTTP server and serves a directory in your browser so you can quickly view your markdown files. It comes with some neat features like:

      \n\n
        \n
      • Monitoring files - it will monitor files for changes and reload them whenever needed
      • \n
      • Hot reloading - whenever the file you are currently viewing changes, the tab in your browser will reload automatically.
      • \n
      • Code Highlight - All blocks of code in most common languages will be color highlighted.
      • \n
      • Themes - choose from multiple themes like: solarized, monokai, github, dracula...
      • \n
      \n\n

      Link: gomd

      \n\n

      For now its only available from AUR or built from source.

      \n\n

      \n\n

      Any tips or feedback will be greatly appreciated :)

      \n
      ", + "author": "wwojtekk", + "publication_date": "2020-07-21T20:07:31Z", + "url": "https://www.reddit.com/r/linux/comments/hvcg44/gomd_quickly_display_formatted_markdown_files/", + "read": false, + "rule": 80, + "remote_identifier": "hvcg44" + } +}, +{ + "model": "core.post", + "pk": 3167, + "fields": { + "created": "2020-07-21T20:14:50.503Z", + "modified": "2020-07-21T20:14:50.543Z", + "title": "They're not otherwise wrong, but it didn't become a real Internet standard until 2017.", + "body": "
      \"They're
      ", + "author": "foodown", + "publication_date": "2020-07-21T21:39:09Z", + "url": "https://www.reddit.com/r/linux/comments/hve7l5/theyre_not_otherwise_wrong_but_it_didnt_become_a/", + "read": false, + "rule": 80, + "remote_identifier": "hve7l5" + } +}, +{ + "model": "core.post", + "pk": 3168, + "fields": { + "created": "2020-07-21T20:14:50.503Z", + "modified": "2020-07-21T20:14:50.545Z", + "title": "Drawing - an alternative to Paint for Linux (gtk3, support HiDPI)", + "body": "", + "author": "dontdieych", + "publication_date": "2020-07-21T02:37:22Z", + "url": "https://www.reddit.com/r/linux/comments/huxgsg/drawing_an_alternative_to_paint_for_linux_gtk3/", + "read": false, + "rule": 80, + "remote_identifier": "huxgsg" + } +}, +{ + "model": "core.post", + "pk": 3169, + "fields": { + "created": "2020-07-21T20:14:50.509Z", + "modified": "2020-07-21T20:14:50.547Z", + "title": "Observations on a Linux issue with 3.5mm earphones with a mic", + "body": "

      Alright hello. I have come from r/SolusProject and I made a post there to do with headphone issues. I suggest you read through the post and comments to get a better understanding before reading this https://www.reddit.com/r/SolusProject/comments/hsql4d/frustrating_headphone_issues/. I had posted to do with it again, but it got taken down for duplication (when it wasn't duplication). This post is more of my observations from experimenting and such. There are distros I haven't tried but I tried a wide range of distros like manjaro, ubuntu based ones and all solus flavors, and I was looking more for how well they worked out of the box, rather than with fiddling around with pulse, hdajack etc which I know will work eventually. If you stumbled across this from searching about the same issue I have (or similar) or are confused to what this is about, I suggest you look at my previous post also.

      \n\n

      So anyways, I've tried the past few days mounting isos to usb drives and trying live os and installing various distros to see about the headphone issue. And my conclusion is that this issue affects the linux kernel in some way across the board. I don't really understand why completely but I have some kind of idea.

      \n\n

      From installing fresh distros, I noticed that the earphones (they are 3.5mm earphones + mic) get recognised as a microphone and not as a speaker system of some kind. Every single time I had a look at the sound settings and in pulse, they came up as plugged microphone, with the internal speakers being the only output device every single time. It's really odd seeing as how ubuntu 14.04 and xubuntu etc from years past worked flawlessly with the earphones, even manjaro a while ago on my older craptop worked fine. I don't really understand why it doesn't work on my device now.

      \n\n

      I'll leave my specs at the bottom of this post but what I think is is there's something the manufacturer did, or something like the cpu causes issue with linux. The manufacturer of my laptop is Lenovo, and the cpu/igpu is from AMD. A warning sign is that when installing a linux distro, it doesn't bring up the dual boot menu at startup like it should. Instead it completely hides the fact it exists until I use something like easyuefi to add an option for that distro, how it works is you specify the boot partition, whether it's linux or windows and the loader conf file for the distro. All of this hassle everytime doesn't appear on my craptop, because the dual boot menu appears flawlessly without issue. May be because it uses an Intel cpu/igpu unlike my newer laptop but it's hard to say.

      \n\n

      Also, it seems like the devices that appear in a given distro when looking at alsa, is hd generic devices but by reloading alsa or any command that shows the full name of the device, it says it's Intel. I don't know if that would be an issue, maybe amd use intel sound drivers or something. It's odd nonetheless.

      \n\n

      This issue has been boggling my mind for obvious reasons, with half-rhetorical questions like does linux not support the earphones anymore, whether out of accident from an overlooked bug in an update or intentionally phasing out? Is any of this AMD or Lenovo's fault? Even with proper headphones or something, will they fail? I don't think anyone here really knows, hell I'd bet an extreme that no one really understands why in the linux community. I kinda rambled in this post with stuff that should've been said in the last post/thread, but I'm saying it now.

      \n\n

      Thanks for contributing thus far to this discussion in figuring this out.

      \n\n

      Specs: AMD Ryzen 5 3500U Mobile CPU (2.2 - 3.7ghz quad core)

      \n\n

      Radeon Vega 8 Integrated GPU, 8GB Ram, 256GB SSD.

      \n\n

      Lenovo C340-14API Laptop

      \n
      ", + "author": "BrianMeerkatlol", + "publication_date": "2020-07-21T21:02:19Z", + "url": "https://www.reddit.com/r/linux/comments/hvdi3o/observations_on_a_linux_issue_with_35mm_earphones/", + "read": false, + "rule": 80, + "remote_identifier": "hvdi3o" + } +}, +{ + "model": "core.post", + "pk": 3170, + "fields": { + "created": "2020-07-21T20:14:50.509Z", + "modified": "2020-07-21T20:14:50.549Z", + "title": "South Korean distro HamoniKR OS has been added to Distrowatch", + "body": "", + "author": "TheHordeRisesAgain", + "publication_date": "2020-07-21T07:44:21Z", + "url": "https://www.reddit.com/r/linux/comments/hv1ug1/south_korean_distro_hamonikr_os_has_been_added_to/", + "read": false, + "rule": 80, + "remote_identifier": "hv1ug1" + } +}, +{ + "model": "core.post", + "pk": 3171, + "fields": { + "created": "2020-07-21T20:14:50.509Z", + "modified": "2020-07-21T20:14:50.559Z", + "title": "The Jailer is free! New release of the outstanding database subsetter and browser is available.", + "body": "", + "author": "Plane-Discussion", + "publication_date": "2020-07-21T12:53:54Z", + "url": "https://www.reddit.com/r/linux/comments/hv5b0j/the_jailer_is_free_new_release_of_the_outstanding/", + "read": false, + "rule": 80, + "remote_identifier": "hv5b0j" + } +}, +{ + "model": "core.post", + "pk": 3172, + "fields": { + "created": "2020-07-21T20:14:50.513Z", + "modified": "2020-07-21T20:14:50.563Z", + "title": "A few very well-aged excerpts from Microsoft\u2019s infamous 2004 \u201cGet the facts\u201d campaign, where they make the case for Windows servers being cheaper, more secure, and more performant than Linux servers", + "body": "
      \n

      Get the facts on Windows and Linux.

      \n\n

      Leading companies and third-party analysts confirm it: Windows has a lower total cost of ownership and outperforms Linux.

      \n\n

      ...

      \n\n

      -Security

      \n\n

      Windows Users Have Fewer Vulnerabilities

      \n
      \n\n

      And then literally the very next bullet point:

      \n\n
      \n

      -Featured Customer Case Study

      \n\n

      Equifax

      \n\n

      Equifax Sees 14 Percent Cost Savings

      \n\n

      Find out why Equifax, a global leader in transforming data into intelligence, selected Windows over Linux to enhance the speed and performance of its marketing services capabilities. Using Microsoft Windows Server System, the company has seen 14 percent in cost savings over Linux.

      \n
      \n\n

      Good thing they saved 14% and got all that extra security! Sure their website is janky and their login flow is downright horrifying (Check it out if you want to be amazed), but who could blame them? Linux is \u201cProhibitively Expensive, Extremely Complex, and Provides No Tangible Business Gains\u201d, Microsoft said so!

      \n\n

      Source: https://web.archive.org/web/20041027003759/http://www.microsoft.com/windowsserversystem/facts/default.mspx

      \n
      ", + "author": "kevinhaze", + "publication_date": "2020-07-20T21:42:15Z", + "url": "https://www.reddit.com/r/linux/comments/hus5lz/a_few_very_wellaged_excerpts_from_microsofts/", + "read": false, + "rule": 80, + "remote_identifier": "hus5lz" + } +}, +{ + "model": "core.post", + "pk": 3173, + "fields": { + "created": "2020-07-21T20:14:50.515Z", + "modified": "2020-07-21T20:14:50.566Z", + "title": "Are there are any professional audio recording studios or artists that use Linux?", + "body": "

      As the title says, who is using Linux as a professional audio engineer, producer, or artist? I am a former Mac user myself, and I am seeing people from time to time who have become disillusioned with what Apple has been doing for the past few years. However, I'm not sure if Linux really has a place for these people to land if they are serious about what they do.

      \n\n

      Fedora Design Suite and Ubuntu Studio are definitely encouraging to see, but what is their real-world usage like? Are we getting better with professional audio in Linux, or have things been stagnant for years?

      \n
      ", + "author": "RootHouston", + "publication_date": "2020-07-21T00:08:26Z", + "url": "https://www.reddit.com/r/linux/comments/huuxvq/are_there_are_any_professional_audio_recording/", + "read": false, + "rule": 80, + "remote_identifier": "huuxvq" + } +}, +{ + "model": "core.post", + "pk": 3174, + "fields": { + "created": "2020-07-21T20:14:50.515Z", + "modified": "2020-07-21T20:14:50.570Z", + "title": "When Linux had marketing", + "body": "", + "author": "Commodore256", + "publication_date": "2020-07-21T14:03:56Z", + "url": "https://www.reddit.com/r/linux/comments/hv65oa/when_linux_had_marketing/", + "read": false, + "rule": 80, + "remote_identifier": "hv65oa" + } +}, +{ + "model": "core.post", + "pk": 3175, + "fields": { + "created": "2020-07-21T20:14:50.520Z", + "modified": "2020-07-21T20:14:50.598Z", + "title": "Ward: Simple and minimalistic server dashboard", + "body": "

      Ward is a simple and and minimalistic server monitoring tool. Ward supports adaptive design system. Also it supports dark theme. It shows only principal information and can be used, if you want to see nice looking dashboard instead looking on bunch of numbers and graphs. Ward works nice on all popular operating systems, because it uses OSHI.

      \n\n

      https://preview.redd.it/gdppswc3a3c51.png?width=1448&format=png&auto=webp&s=0d6e10146c105ddcfd045dd59c970d4c127ddb8c

      \n\n

      https://github.com/B-Software/Ward

      \n
      ", + "author": "Pabyzu", + "publication_date": "2020-07-21T00:33:40Z", + "url": "https://www.reddit.com/r/linux/comments/huvea3/ward_simple_and_minimalistic_server_dashboard/", + "read": false, + "rule": 80, + "remote_identifier": "huvea3" + } +}, +{ + "model": "core.post", + "pk": 3176, + "fields": { + "created": "2020-07-21T20:14:50.522Z", + "modified": "2020-07-21T20:14:50.606Z", + "title": "WindowsFX - a good Windows alternative?", + "body": "

      I would personally like to hear some of your opinions (in the replies) about WindowsFX. What is WindowsFX you may ask? WindowsFX is a Brazilian linux distribution that is designed to look and act like Windows 10.

      \n\n

      Linux / WindowsFX is based off of Ubuntu, and uses Cinnamon as its DE. Upon first boot, normal Windows users can tell the difference. But if you were to put it in front of a non tech-savvy person, they wouldn't be able to tell the difference.

      \n\n

      Personally, with WSL on Windows, I see no need for a distro like this. However, as I said, I would like to hear your opinions on this distro.

      \n\n

      Video review here.

      \n
      ", + "author": "Demonitized101", + "publication_date": "2020-07-20T23:03:29Z", + "url": "https://www.reddit.com/r/linux/comments/hutpt5/windowsfx_a_good_windows_alternative/", + "read": false, + "rule": 80, + "remote_identifier": "hutpt5" + } +}, +{ + "model": "core.post", + "pk": 3177, + "fields": { + "created": "2020-07-21T20:14:50.775Z", + "modified": "2020-07-21T20:14:50.780Z", + "title": "Every day this good boy brings a carrot to his best buddy", + "body": "
      ", + "author": "TooShiftyForYou", + "publication_date": "2020-07-21T15:25:31Z", + "url": "https://www.reddit.com/r/aww/comments/hv7a8b/every_day_this_good_boy_brings_a_carrot_to_his/", + "read": false, + "rule": 81, + "remote_identifier": "hv7a8b" + } +}, +{ + "model": "core.post", + "pk": 3178, + "fields": { + "created": "2020-07-21T20:14:50.775Z", + "modified": "2020-07-25T20:08:34.264Z", + "title": "Kitten mimics his human petting the dog", + "body": "
      ", + "author": "SpecterAscendant", + "publication_date": "2020-07-21T14:56:57Z", + "url": "https://www.reddit.com/r/aww/comments/hv6ve3/kitten_mimics_his_human_petting_the_dog/", + "read": true, + "rule": 81, + "remote_identifier": "hv6ve3" + } +}, +{ + "model": "core.post", + "pk": 3179, + "fields": { + "created": "2020-07-21T20:14:50.775Z", + "modified": "2020-07-21T20:14:50.789Z", + "title": "My fox friend!", + "body": "
      ", + "author": "Zepantha", + "publication_date": "2020-07-21T14:27:25Z", + "url": "https://www.reddit.com/r/aww/comments/hv6gte/my_fox_friend/", + "read": false, + "rule": 81, + "remote_identifier": "hv6gte" + } +}, +{ + "model": "core.post", + "pk": 3180, + "fields": { + "created": "2020-07-21T20:14:50.775Z", + "modified": "2020-07-21T20:15:46.876Z", + "title": "Ducks annihilate peas", + "body": "
      ", + "author": "tommycalibre", + "publication_date": "2020-07-21T17:12:40Z", + "url": "https://www.reddit.com/r/aww/comments/hv9258/ducks_annihilate_peas/", + "read": true, + "rule": 81, + "remote_identifier": "hv9258" + } +}, +{ + "model": "core.post", + "pk": 3181, + "fields": { + "created": "2020-07-21T20:14:50.775Z", + "modified": "2020-07-21T20:14:50.797Z", + "title": "Wiggle it baby", + "body": "
      ", + "author": "neo_star", + "publication_date": "2020-07-21T18:44:31Z", + "url": "https://www.reddit.com/r/aww/comments/hvaucy/wiggle_it_baby/", + "read": false, + "rule": 81, + "remote_identifier": "hvaucy" + } +}, +{ + "model": "core.post", + "pk": 3182, + "fields": { + "created": "2020-07-21T20:14:50.776Z", + "modified": "2020-07-21T20:16:22.725Z", + "title": "I guess I should do this.. everyone seems to be liking little pups and kittens so.. Reddit, meet bailey", + "body": "
      \"I
      ", + "author": "X_XNOTHINGX_X", + "publication_date": "2020-07-21T14:15:08Z", + "url": "https://www.reddit.com/r/aww/comments/hv6b0a/i_guess_i_should_do_this_everyone_seems_to_be/", + "read": true, + "rule": 81, + "remote_identifier": "hv6b0a" + } +}, +{ + "model": "core.post", + "pk": 3183, + "fields": { + "created": "2020-07-21T20:14:50.776Z", + "modified": "2020-07-21T20:14:50.806Z", + "title": "The hat makes the crab.", + "body": "
      \"The
      ", + "author": "fujfuj", + "publication_date": "2020-07-21T14:48:40Z", + "url": "https://www.reddit.com/r/aww/comments/hv6rde/the_hat_makes_the_crab/", + "read": false, + "rule": 81, + "remote_identifier": "hv6rde" + } +}, +{ + "model": "core.post", + "pk": 3184, + "fields": { + "created": "2020-07-21T20:14:50.776Z", + "modified": "2020-07-21T20:14:50.812Z", + "title": "Baby bunny fits in hand", + "body": "
      ", + "author": "Hawken10", + "publication_date": "2020-07-21T12:31:30Z", + "url": "https://www.reddit.com/r/aww/comments/hv5253/baby_bunny_fits_in_hand/", + "read": false, + "rule": 81, + "remote_identifier": "hv5253" + } +}, +{ + "model": "core.post", + "pk": 3185, + "fields": { + "created": "2020-07-21T20:14:50.776Z", + "modified": "2020-07-21T20:14:50.818Z", + "title": "My cat and I, both pregnant", + "body": "
      \"My
      ", + "author": "nixdionisio", + "publication_date": "2020-07-21T11:06:25Z", + "url": "https://www.reddit.com/r/aww/comments/hv44m2/my_cat_and_i_both_pregnant/", + "read": false, + "rule": 81, + "remote_identifier": "hv44m2" + } +}, +{ + "model": "core.post", + "pk": 3186, + "fields": { + "created": "2020-07-21T20:14:50.776Z", + "modified": "2020-07-21T20:14:50.822Z", + "title": "Very sweet dance", + "body": "
      ", + "author": "Ashley1023", + "publication_date": "2020-07-21T13:03:03Z", + "url": "https://www.reddit.com/r/aww/comments/hv5ewq/very_sweet_dance/", + "read": false, + "rule": 81, + "remote_identifier": "hv5ewq" + } +}, +{ + "model": "core.post", + "pk": 3187, + "fields": { + "created": "2020-07-21T20:14:50.776Z", + "modified": "2020-07-21T20:14:50.825Z", + "title": "My local pet-store has a cat named Vegemite \u2764\ufe0f", + "body": "
      \"My
      ", + "author": "galinhad", + "publication_date": "2020-07-21T12:06:17Z", + "url": "https://www.reddit.com/r/aww/comments/hv4s5z/my_local_petstore_has_a_cat_named_vegemite/", + "read": false, + "rule": 81, + "remote_identifier": "hv4s5z" + } +}, +{ + "model": "core.post", + "pk": 3188, + "fields": { + "created": "2020-07-21T20:14:50.777Z", + "modified": "2020-07-21T20:15:01.459Z", + "title": "A teacher like that makes a huge difference", + "body": "
      ", + "author": "Unicornglitteryblood", + "publication_date": "2020-07-21T18:29:57Z", + "url": "https://www.reddit.com/r/aww/comments/hvajo9/a_teacher_like_that_makes_a_huge_difference/", + "read": true, + "rule": 81, + "remote_identifier": "hvajo9" + } +}, +{ + "model": "core.post", + "pk": 3189, + "fields": { + "created": "2020-07-21T20:14:50.777Z", + "modified": "2020-07-22T19:55:49.930Z", + "title": "Kitten Encounters Bubbly Water", + "body": "
      \"Kitten
      ", + "author": "DragonOBunny", + "publication_date": "2020-07-21T15:28:05Z", + "url": "https://www.reddit.com/r/aww/comments/hv7bis/kitten_encounters_bubbly_water/", + "read": true, + "rule": 81, + "remote_identifier": "hv7bis" + } +}, +{ + "model": "core.post", + "pk": 3190, + "fields": { + "created": "2020-07-21T20:14:50.777Z", + "modified": "2020-07-21T20:14:50.833Z", + "title": "Are These My Chickens Now?", + "body": "", + "author": "jasontaken", + "publication_date": "2020-07-21T09:55:36Z", + "url": "https://www.reddit.com/r/aww/comments/hv3de1/are_these_my_chickens_now/", + "read": false, + "rule": 81, + "remote_identifier": "hv3de1" + } +}, +{ + "model": "core.post", + "pk": 3191, + "fields": { + "created": "2020-07-21T20:14:50.777Z", + "modified": "2020-07-25T20:08:20.518Z", + "title": "Our St Bernard 6 months apart", + "body": "
      \"Our
      ", + "author": "ryan3105", + "publication_date": "2020-07-21T18:00:04Z", + "url": "https://www.reddit.com/r/aww/comments/hv9yea/our_st_bernard_6_months_apart/", + "read": true, + "rule": 81, + "remote_identifier": "hv9yea" + } +}, +{ + "model": "core.post", + "pk": 3192, + "fields": { + "created": "2020-07-21T20:14:50.777Z", + "modified": "2020-07-21T20:14:50.837Z", + "title": "Father and child in sync", + "body": "
      ", + "author": "Araragi_Monogatari", + "publication_date": "2020-07-21T08:29:18Z", + "url": "https://www.reddit.com/r/aww/comments/hv2enj/father_and_child_in_sync/", + "read": false, + "rule": 81, + "remote_identifier": "hv2enj" + } +}, +{ + "model": "core.post", + "pk": 3193, + "fields": { + "created": "2020-07-21T20:14:50.778Z", + "modified": "2020-07-21T20:14:50.840Z", + "title": "A meme is born", + "body": "
      \"A
      ", + "author": "Unicornglitteryblood", + "publication_date": "2020-07-21T18:55:04Z", + "url": "https://www.reddit.com/r/aww/comments/hvb1vh/a_meme_is_born/", + "read": false, + "rule": 81, + "remote_identifier": "hvb1vh" + } +}, +{ + "model": "core.post", + "pk": 3194, + "fields": { + "created": "2020-07-21T20:14:50.778Z", + "modified": "2020-07-21T20:14:50.842Z", + "title": "She bites, then she sleeps, then bites again, then sleeps again. \ud83d\ude02", + "body": "
      ", + "author": "earlymauvs", + "publication_date": "2020-07-21T11:34:19Z", + "url": "https://www.reddit.com/r/aww/comments/hv4fat/she_bites_then_she_sleeps_then_bites_again_then/", + "read": false, + "rule": 81, + "remote_identifier": "hv4fat" + } +}, +{ + "model": "core.post", + "pk": 3195, + "fields": { + "created": "2020-07-21T20:14:50.778Z", + "modified": "2020-07-21T20:14:50.844Z", + "title": "Nothing calmer that 2 ginger cats rubbing heads and showing their love in morning", + "body": "
      \"Nothing
      ", + "author": "Apotheosis33", + "publication_date": "2020-07-21T08:39:24Z", + "url": "https://www.reddit.com/r/aww/comments/hv2j2g/nothing_calmer_that_2_ginger_cats_rubbing_heads/", + "read": false, + "rule": 81, + "remote_identifier": "hv2j2g" + } +}, +{ + "model": "core.post", + "pk": 3196, + "fields": { + "created": "2020-07-21T20:14:50.778Z", + "modified": "2020-07-21T20:14:50.851Z", + "title": "Ring Tailed Possum", + "body": "", + "author": "Wayward-Delver", + "publication_date": "2020-07-21T11:23:51Z", + "url": "https://www.reddit.com/r/aww/comments/hv4b9e/ring_tailed_possum/", + "read": false, + "rule": 81, + "remote_identifier": "hv4b9e" + } +}, +{ + "model": "core.post", + "pk": 3197, + "fields": { + "created": "2020-07-21T20:14:50.778Z", + "modified": "2020-07-21T20:14:50.854Z", + "title": "Baby scooby in sad mood....", + "body": "
      \"Baby
      ", + "author": "deepanshuahiroo7", + "publication_date": "2020-07-21T15:12:23Z", + "url": "https://www.reddit.com/r/aww/comments/hv73ft/baby_scooby_in_sad_mood/", + "read": false, + "rule": 81, + "remote_identifier": "hv73ft" + } +}, +{ + "model": "core.post", + "pk": 3198, + "fields": { + "created": "2020-07-21T20:14:50.779Z", + "modified": "2020-07-21T20:14:50.856Z", + "title": "New friends!", + "body": "
      \"New
      ", + "author": "HelentotheKeller", + "publication_date": "2020-07-21T13:10:48Z", + "url": "https://www.reddit.com/r/aww/comments/hv5i6i/new_friends/", + "read": false, + "rule": 81, + "remote_identifier": "hv5i6i" + } +}, +{ + "model": "core.post", + "pk": 3199, + "fields": { + "created": "2020-07-21T20:14:50.779Z", + "modified": "2020-07-21T20:14:50.858Z", + "title": "When you haven't chewed anything for 1 second", + "body": "
      \"When
      ", + "author": "Tanay4", + "publication_date": "2020-07-21T10:26:53Z", + "url": "https://www.reddit.com/r/aww/comments/hv3pl0/when_you_havent_chewed_anything_for_1_second/", + "read": false, + "rule": 81, + "remote_identifier": "hv3pl0" + } +}, +{ + "model": "core.post", + "pk": 3200, + "fields": { + "created": "2020-07-21T20:14:50.779Z", + "modified": "2020-07-21T20:17:01.490Z", + "title": "Mango Derp", + "body": "
      \"Mango
      ", + "author": "sheetglass", + "publication_date": "2020-07-21T13:27:26Z", + "url": "https://www.reddit.com/r/aww/comments/hv5p8s/mango_derp/", + "read": true, + "rule": 81, + "remote_identifier": "hv5p8s" + } +}, +{ + "model": "core.post", + "pk": 3201, + "fields": { + "created": "2020-07-21T20:14:50.779Z", + "modified": "2020-07-21T20:14:50.863Z", + "title": "My guy turns 20 next month", + "body": "
      \"My
      ", + "author": "alozsoc", + "publication_date": "2020-07-21T06:34:26Z", + "url": "https://www.reddit.com/r/aww/comments/hv0xp1/my_guy_turns_20_next_month/", + "read": false, + "rule": 81, + "remote_identifier": "hv0xp1" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add log entry", + "content_type": [ + "admin", + "logentry" + ], + "codename": "add_logentry" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change log entry", + "content_type": [ + "admin", + "logentry" + ], + "codename": "change_logentry" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete log entry", + "content_type": [ + "admin", + "logentry" + ], + "codename": "delete_logentry" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view log entry", + "content_type": [ + "admin", + "logentry" + ], + "codename": "view_logentry" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add permission", + "content_type": [ + "auth", + "permission" + ], + "codename": "add_permission" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change permission", + "content_type": [ + "auth", + "permission" + ], + "codename": "change_permission" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete permission", + "content_type": [ + "auth", + "permission" + ], + "codename": "delete_permission" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view permission", + "content_type": [ + "auth", + "permission" + ], + "codename": "view_permission" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add group", + "content_type": [ + "auth", + "group" + ], + "codename": "add_group" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change group", + "content_type": [ + "auth", + "group" + ], + "codename": "change_group" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete group", + "content_type": [ + "auth", + "group" + ], + "codename": "delete_group" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view group", + "content_type": [ + "auth", + "group" + ], + "codename": "view_group" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add content type", + "content_type": [ + "contenttypes", + "contenttype" + ], + "codename": "add_contenttype" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change content type", + "content_type": [ + "contenttypes", + "contenttype" + ], + "codename": "change_contenttype" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete content type", + "content_type": [ + "contenttypes", + "contenttype" + ], + "codename": "delete_contenttype" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view content type", + "content_type": [ + "contenttypes", + "contenttype" + ], + "codename": "view_contenttype" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add session", + "content_type": [ + "sessions", + "session" + ], + "codename": "add_session" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change session", + "content_type": [ + "sessions", + "session" + ], + "codename": "change_session" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete session", + "content_type": [ + "sessions", + "session" + ], + "codename": "delete_session" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view session", + "content_type": [ + "sessions", + "session" + ], + "codename": "view_session" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add crontab", + "content_type": [ + "django_celery_beat", + "crontabschedule" + ], + "codename": "add_crontabschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change crontab", + "content_type": [ + "django_celery_beat", + "crontabschedule" + ], + "codename": "change_crontabschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete crontab", + "content_type": [ + "django_celery_beat", + "crontabschedule" + ], + "codename": "delete_crontabschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view crontab", + "content_type": [ + "django_celery_beat", + "crontabschedule" + ], + "codename": "view_crontabschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add interval", + "content_type": [ + "django_celery_beat", + "intervalschedule" + ], + "codename": "add_intervalschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change interval", + "content_type": [ + "django_celery_beat", + "intervalschedule" + ], + "codename": "change_intervalschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete interval", + "content_type": [ + "django_celery_beat", + "intervalschedule" + ], + "codename": "delete_intervalschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view interval", + "content_type": [ + "django_celery_beat", + "intervalschedule" + ], + "codename": "view_intervalschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add periodic task", + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "codename": "add_periodictask" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change periodic task", + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "codename": "change_periodictask" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete periodic task", + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "codename": "delete_periodictask" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view periodic task", + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "codename": "view_periodictask" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add periodic tasks", + "content_type": [ + "django_celery_beat", + "periodictasks" + ], + "codename": "add_periodictasks" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change periodic tasks", + "content_type": [ + "django_celery_beat", + "periodictasks" + ], + "codename": "change_periodictasks" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete periodic tasks", + "content_type": [ + "django_celery_beat", + "periodictasks" + ], + "codename": "delete_periodictasks" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view periodic tasks", + "content_type": [ + "django_celery_beat", + "periodictasks" + ], + "codename": "view_periodictasks" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add solar event", + "content_type": [ + "django_celery_beat", + "solarschedule" + ], + "codename": "add_solarschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change solar event", + "content_type": [ + "django_celery_beat", + "solarschedule" + ], + "codename": "change_solarschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete solar event", + "content_type": [ + "django_celery_beat", + "solarschedule" + ], + "codename": "delete_solarschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view solar event", + "content_type": [ + "django_celery_beat", + "solarschedule" + ], + "codename": "view_solarschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add clocked", + "content_type": [ + "django_celery_beat", + "clockedschedule" + ], + "codename": "add_clockedschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change clocked", + "content_type": [ + "django_celery_beat", + "clockedschedule" + ], + "codename": "change_clockedschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete clocked", + "content_type": [ + "django_celery_beat", + "clockedschedule" + ], + "codename": "delete_clockedschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view clocked", + "content_type": [ + "django_celery_beat", + "clockedschedule" + ], + "codename": "view_clockedschedule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add registration profile", + "content_type": [ + "registration", + "registrationprofile" + ], + "codename": "add_registrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change registration profile", + "content_type": [ + "registration", + "registrationprofile" + ], + "codename": "change_registrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete registration profile", + "content_type": [ + "registration", + "registrationprofile" + ], + "codename": "delete_registrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view registration profile", + "content_type": [ + "registration", + "registrationprofile" + ], + "codename": "view_registrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add supervised registration profile", + "content_type": [ + "registration", + "supervisedregistrationprofile" + ], + "codename": "add_supervisedregistrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change supervised registration profile", + "content_type": [ + "registration", + "supervisedregistrationprofile" + ], + "codename": "change_supervisedregistrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete supervised registration profile", + "content_type": [ + "registration", + "supervisedregistrationprofile" + ], + "codename": "delete_supervisedregistrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view supervised registration profile", + "content_type": [ + "registration", + "supervisedregistrationprofile" + ], + "codename": "view_supervisedregistrationprofile" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add access attempt", + "content_type": [ + "axes", + "accessattempt" + ], + "codename": "add_accessattempt" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change access attempt", + "content_type": [ + "axes", + "accessattempt" + ], + "codename": "change_accessattempt" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete access attempt", + "content_type": [ + "axes", + "accessattempt" + ], + "codename": "delete_accessattempt" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view access attempt", + "content_type": [ + "axes", + "accessattempt" + ], + "codename": "view_accessattempt" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add access log", + "content_type": [ + "axes", + "accesslog" + ], + "codename": "add_accesslog" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change access log", + "content_type": [ + "axes", + "accesslog" + ], + "codename": "change_accesslog" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete access log", + "content_type": [ + "axes", + "accesslog" + ], + "codename": "delete_accesslog" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view access log", + "content_type": [ + "axes", + "accesslog" + ], + "codename": "view_accesslog" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add user", + "content_type": [ + "accounts", + "user" + ], + "codename": "add_user" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change user", + "content_type": [ + "accounts", + "user" + ], + "codename": "change_user" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete user", + "content_type": [ + "accounts", + "user" + ], + "codename": "delete_user" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view user", + "content_type": [ + "accounts", + "user" + ], + "codename": "view_user" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add post", + "content_type": [ + "core", + "post" + ], + "codename": "add_post" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change post", + "content_type": [ + "core", + "post" + ], + "codename": "change_post" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete post", + "content_type": [ + "core", + "post" + ], + "codename": "delete_post" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view post", + "content_type": [ + "core", + "post" + ], + "codename": "view_post" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add Category", + "content_type": [ + "core", + "category" + ], + "codename": "add_category" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change Category", + "content_type": [ + "core", + "category" + ], + "codename": "change_category" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete Category", + "content_type": [ + "core", + "category" + ], + "codename": "delete_category" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view Category", + "content_type": [ + "core", + "category" + ], + "codename": "view_category" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can add collection rule", + "content_type": [ + "collection", + "collectionrule" + ], + "codename": "add_collectionrule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can change collection rule", + "content_type": [ + "collection", + "collectionrule" + ], + "codename": "change_collectionrule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can delete collection rule", + "content_type": [ + "collection", + "collectionrule" + ], + "codename": "delete_collectionrule" + } +}, +{ + "model": "auth.permission", + "fields": { + "name": "Can view collection rule", + "content_type": [ + "collection", + "collectionrule" + ], + "codename": "view_collectionrule" + } +}, +{ + "model": "accounts.user", + "fields": { + "password": "pbkdf2_sha256$180000$U9a2CS9X0b8Y$T6bD/VoUOFoGNIp16aFlOL0N7q0e6A3I97ypm/AhsGo=", + "last_login": "2020-07-21T20:14:35.966Z", + "is_superuser": true, + "first_name": "", + "last_name": "", + "is_staff": true, + "is_active": true, + "date_joined": "2019-07-18T18:52:36.080Z", + "email": "sonny@bakker.nl", + "task": 10, + "reddit_refresh_token": null, + "reddit_access_token": null, + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "core.category", + "pk": 8, + "fields": { + "created": "2019-11-17T19:37:24.671Z", + "modified": "2019-11-18T19:59:55.010Z", + "name": "World news", + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "core.category", + "pk": 9, + "fields": { + "created": "2019-11-17T19:37:26.161Z", + "modified": "2020-05-30T13:36:10.509Z", + "name": "Tech", + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 3, + "fields": { + "created": "2019-07-14T13:08:10.374Z", + "modified": "2020-07-14T11:45:30.680Z", + "name": "Hackers News", + "type": "feed", + "url": "https://news.ycombinator.com/rss", + "website_url": "https://news.ycombinator.com/", + "favicon": "https://news.ycombinator.com/favicon.ico", + "timezone": "UTC", + "category": 9, + "last_run": "2020-07-14T11:45:30.477Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 4, + "fields": { + "created": "2019-07-20T11:24:32.745Z", + "modified": "2020-07-14T11:45:29.357Z", + "name": "BBC", + "type": "feed", + "url": "http://feeds.bbci.co.uk/news/world/rss.xml", + "website_url": "https://www.bbc.co.uk/news/", + "favicon": "https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png", + "timezone": "UTC", + "category": 8, + "last_run": "2020-07-14T11:45:28.863Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 5, + "fields": { + "created": "2019-07-20T11:24:50.411Z", + "modified": "2020-07-14T11:45:30.063Z", + "name": "Ars Technica", + "type": "feed", + "url": "http://feeds.arstechnica.com/arstechnica/index?fmt=xml", + "website_url": "https://arstechnica.com", + "favicon": "https://cdn.arstechnica.net/favicon.ico", + "timezone": "UTC", + "category": 9, + "last_run": "2020-07-14T11:45:29.810Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 6, + "fields": { + "created": "2019-07-20T11:25:02.089Z", + "modified": "2020-07-14T11:45:30.473Z", + "name": "The Guardian", + "type": "feed", + "url": "https://www.theguardian.com/world/rss", + "website_url": "https://www.theguardian.com/world", + "favicon": "https://assets.guim.co.uk/images/favicons/873381bf11d58e20f551905d51575117/72x72.png", + "timezone": "UTC", + "category": 8, + "last_run": "2020-07-14T11:45:30.181Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 7, + "fields": { + "created": "2019-07-20T11:25:30.121Z", + "modified": "2020-07-14T11:45:29.807Z", + "name": "Tweakers", + "type": "feed", + "url": "http://feeds.feedburner.com/tweakers/mixed?fmt=xml", + "website_url": "https://tweakers.net/", + "favicon": null, + "timezone": "UTC", + "category": 9, + "last_run": "2020-07-14T11:45:29.525Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 8, + "fields": { + "created": "2019-07-20T11:25:46.256Z", + "modified": "2020-07-14T11:45:30.179Z", + "name": "The Verge", + "type": "feed", + "url": "https://www.theverge.com/rss/index.xml", + "website_url": "https://www.theverge.com/", + "favicon": "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395367/favicon-16x16.0.png", + "timezone": "UTC", + "category": 9, + "last_run": "2020-07-14T11:45:30.066Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 9, + "fields": { + "created": "2019-11-24T15:28:41.399Z", + "modified": "2020-07-14T11:45:29.522Z", + "name": "NOS", + "type": "feed", + "url": "http://feeds.nos.nl/nosnieuwsalgemeen", + "website_url": null, + "favicon": null, + "timezone": "Europe/Amsterdam", + "category": 8, + "last_run": "2020-07-14T11:45:29.362Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 80, + "fields": { + "created": "2020-07-08T19:30:10.638Z", + "modified": "2020-07-21T20:14:50.609Z", + "name": "Linux subreddit", + "type": "subreddit", + "url": "https://oauth.reddit.com/r/linux/hot", + "website_url": null, + "favicon": null, + "timezone": "UTC", + "category": 9, + "last_run": "2020-07-21T20:14:50.492Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 81, + "fields": { + "created": "2020-07-08T19:30:33.590Z", + "modified": "2020-07-21T20:14:50.865Z", + "name": "AWW subreddit", + "type": "subreddit", + "url": "https://oauth.reddit.com/r/aww/hot", + "website_url": null, + "favicon": null, + "timezone": "UTC", + "category": 8, + "last_run": "2020-07-21T20:14:50.768Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "collection.collectionrule", + "pk": 82, + "fields": { + "created": "2020-07-20T19:29:37.675Z", + "modified": "2020-07-21T20:14:50.489Z", + "name": "Star citizen subreddit", + "type": "subreddit", + "url": "https://oauth.reddit.com/r/starcitizen/hot.json", + "website_url": null, + "favicon": null, + "timezone": "UTC", + "category": 9, + "last_run": "2020-07-21T20:14:50.355Z", + "succeeded": true, + "error": null, + "enabled": true, + "user": [ + "sonny@bakker.nl" + ] + } +}, +{ + "model": "admin.logentry", + "pk": 1, + "fields": { + "action_time": "2020-05-24T18:38:44.624Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "django_celery_beat", + "intervalschedule" + ], + "object_id": "5", + "object_repr": "every 4 hours", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 2, + "fields": { + "action_time": "2020-05-24T18:38:46.689Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "object_id": "10", + "object_repr": "sonny@bakker.nl-collection-task: every 4 hours", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Interval Schedule\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 3, + "fields": { + "action_time": "2020-05-24T18:39:09.203Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "object_id": "26", + "object_repr": "sonnyba871@gmail.com-collection-task: every hour", + "action_flag": 3, + "change_message": "" + } +}, +{ + "model": "admin.logentry", + "pk": 4, + "fields": { + "action_time": "2020-05-24T19:46:50.248Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "object_id": "10", + "object_repr": "sonny@bakker.nl-collection-task: every 4 hours", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Positional Arguments\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 5, + "fields": { + "action_time": "2020-07-07T19:37:57.086Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "accounts", + "user" + ], + "object_id": "1", + "object_repr": "sonny@bakker.nl", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Reddit refresh token\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 6, + "fields": { + "action_time": "2020-07-07T19:39:46.160Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "object_id": "10", + "object_repr": "sonny@bakker.nl-collection-task: every 4 hours", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Task (registered)\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 7, + "fields": { + "action_time": "2020-07-08T19:29:27.025Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "django_celery_beat", + "periodictask" + ], + "object_id": "11", + "object_repr": "Reddit collection task: every 4 hours", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 8, + "fields": { + "action_time": "2020-07-14T11:46:50.039Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "accounts", + "user" + ], + "object_id": "1", + "object_repr": "sonny@bakker.nl", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Reddit access token\", \"Reddit refresh token\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 9, + "fields": { + "action_time": "2020-07-18T19:08:33.997Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "collection", + "collectionrule" + ], + "object_id": "81", + "object_repr": "AWW subreddit", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Url\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 10, + "fields": { + "action_time": "2020-07-18T19:08:44.063Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "collection", + "collectionrule" + ], + "object_id": "80", + "object_repr": "Linux subreddit", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Url\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 11, + "fields": { + "action_time": "2020-07-18T19:17:25.213Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "core", + "post" + ], + "object_id": "2336", + "object_repr": "Post-2336", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 12, + "fields": { + "action_time": "2020-07-18T19:17:40.596Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "core", + "post" + ], + "object_id": "2336", + "object_repr": "Post-2336", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 13, + "fields": { + "action_time": "2020-07-19T10:55:55.807Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "core", + "post" + ], + "object_id": "2764", + "object_repr": "Post-2764", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 14, + "fields": { + "action_time": "2020-07-19T10:57:40.643Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "core", + "post" + ], + "object_id": "2764", + "object_repr": "Post-2764", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 15, + "fields": { + "action_time": "2020-07-19T10:58:05.823Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "core", + "post" + ], + "object_id": "2764", + "object_repr": "Post-2764", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Body\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 16, + "fields": { + "action_time": "2020-07-26T09:51:52.478Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "accounts", + "user" + ], + "object_id": "1", + "object_repr": "sonny@bakker.nl", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"First name\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 17, + "fields": { + "action_time": "2020-07-26T09:52:04.691Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "accounts", + "user" + ], + "object_id": "1", + "object_repr": "sonny@bakker.nl", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"password\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 18, + "fields": { + "action_time": "2020-07-26T09:52:12.392Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "accounts", + "user" + ], + "object_id": "1", + "object_repr": "sonny@bakker.nl", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"First name\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 19, + "fields": { + "action_time": "2020-07-26T09:56:15.949Z", + "user": [ + "sonny@bakker.nl" + ], + "content_type": [ + "accounts", + "user" + ], + "object_id": "1", + "object_repr": "sonny@bakker.nl", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Reddit access token\", \"Reddit refresh token\"]}}]" + } +} +] diff --git a/src/newsreader/fixtures/local/fixture.json b/src/newsreader/fixtures/local/fixture.json index ffcc4fd..99176b5 100644 --- a/src/newsreader/fixtures/local/fixture.json +++ b/src/newsreader/fixtures/local/fixture.json @@ -47,7 +47,7 @@ "user" : 2, "succeeded" : true, "modified" : "2019-07-20T11:28:16.473Z", - "last_suceeded" : "2019-07-20T11:28:16.316Z", + "last_run" : "2019-07-20T11:28:16.316Z", "name" : "Hackers News", "website_url" : null, "created" : "2019-07-14T13:08:10.374Z", @@ -65,7 +65,7 @@ "error" : null, "user" : 2, "succeeded" : true, - "last_suceeded" : "2019-07-20T11:28:15.691Z", + "last_run" : "2019-07-20T11:28:15.691Z", "name" : "BBC", "modified" : "2019-07-20T12:07:49.164Z", "timezone" : "UTC", @@ -85,7 +85,7 @@ "website_url" : null, "name" : "Ars Technica", "succeeded" : true, - "last_suceeded" : "2019-07-20T11:28:15.986Z", + "last_run" : "2019-07-20T11:28:15.986Z", "modified" : "2019-07-20T11:28:16.033Z", "user" : 2 }, @@ -102,7 +102,7 @@ "user" : 2, "name" : "The Guardian", "succeeded" : true, - "last_suceeded" : "2019-07-20T11:28:16.078Z", + "last_run" : "2019-07-20T11:28:16.078Z", "modified" : "2019-07-20T12:07:44.292Z", "created" : "2019-07-20T11:25:02.089Z", "website_url" : null, @@ -119,7 +119,7 @@ "website_url" : null, "created" : "2019-07-20T11:25:30.121Z", "user" : 2, - "last_suceeded" : "2019-07-20T11:28:15.860Z", + "last_run" : "2019-07-20T11:28:15.860Z", "succeeded" : true, "modified" : "2019-07-20T12:07:28.473Z", "name" : "Tweakers" @@ -139,7 +139,7 @@ "website_url" : null, "timezone" : "UTC", "user" : 2, - "last_suceeded" : "2019-07-20T11:28:16.034Z", + "last_run" : "2019-07-20T11:28:16.034Z", "succeeded" : true, "modified" : "2019-07-20T12:07:21.704Z", "name" : "The Verge" diff --git a/src/newsreader/js/pages/categories/App.js b/src/newsreader/js/pages/categories/App.js index 691aaed..a035b46 100644 --- a/src/newsreader/js/pages/categories/App.js +++ b/src/newsreader/js/pages/categories/App.js @@ -69,6 +69,7 @@ class App extends React.Component { key={category.pk} category={category} showDialog={this.selectCategory} + updateUrl={this.props.updateUrl} /> ); }); @@ -80,7 +81,7 @@ class App extends React.Component { const pageHeader = ( <>

      Categories

      - + Create category diff --git a/src/newsreader/js/pages/categories/components/CategoryCard.js b/src/newsreader/js/pages/categories/components/CategoryCard.js index 94bd6f4..2e7cad4 100644 --- a/src/newsreader/js/pages/categories/components/CategoryCard.js +++ b/src/newsreader/js/pages/categories/components/CategoryCard.js @@ -33,7 +33,7 @@ const CategoryCard = props => { <> Edit diff --git a/src/newsreader/js/pages/categories/index.js b/src/newsreader/js/pages/categories/index.js index 9d75bb9..791fdbd 100644 --- a/src/newsreader/js/pages/categories/index.js +++ b/src/newsreader/js/pages/categories/index.js @@ -9,5 +9,15 @@ if (page) { const dataScript = document.getElementById('categories-data'); const categories = JSON.parse(dataScript.textContent); - ReactDOM.render(, page); + let createUrl = document.getElementById('createUrl').textContent; + let updateUrl = document.getElementById('updateUrl').textContent; + + ReactDOM.render( + , + page + ); } diff --git a/src/newsreader/js/pages/homepage/App.js b/src/newsreader/js/pages/homepage/App.js index 91cfa4e..77b6222 100644 --- a/src/newsreader/js/pages/homepage/App.js +++ b/src/newsreader/js/pages/homepage/App.js @@ -19,7 +19,11 @@ class App extends React.Component { return ( <> - + {this.props.error && ( @@ -30,6 +34,10 @@ class App extends React.Component { post={this.props.post} rule={this.props.rule} category={this.props.category} + feedUrl={this.props.feedUrl} + subredditUrl={this.props.subredditUrl} + timelineUrl={this.props.timelineUrl} + categoriesUrl={this.props.categoriesUrl} /> )} diff --git a/src/newsreader/js/pages/homepage/components/PostModal.js b/src/newsreader/js/pages/homepage/components/PostModal.js index 08033bc..5196102 100644 --- a/src/newsreader/js/pages/homepage/components/PostModal.js +++ b/src/newsreader/js/pages/homepage/components/PostModal.js @@ -3,7 +3,13 @@ import { connect } from 'react-redux'; import Cookies from 'js-cookie'; import { unSelectPost, markPostRead } from '../actions/posts.js'; -import { CATEGORY_TYPE, RULE_TYPE, FEED, SUBREDDIT } from '../constants.js'; +import { + CATEGORY_TYPE, + RULE_TYPE, + FEED, + SUBREDDIT, + TWITTER_TIMELINE, +} from '../constants.js'; import { formatDatetime } from '../../../utils.js'; class PostModal extends React.Component { @@ -44,10 +50,15 @@ class PostModal extends React.Component { const post = this.props.post; const publicationDate = formatDatetime(post.publicationDate); const titleClassName = post.read ? 'post__title post__title--read' : 'post__title'; - const ruleUrl = - this.props.rule.type === FEED - ? `/collection/rules/${this.props.rule.id}/` - : `/collection/rules/subreddits/${this.props.rule.id}/`; + let ruleUrl = ''; + + if (this.props.rule.type === SUBREDDIT) { + ruleUrl = `${this.props.subredditUrl}/${this.props.rule.id}/`; + } else if (this.props.rule.type === TWITTER_TIMELINE) { + ruleUrl = `${this.props.timelineUrl}/${this.props.rule.id}/`; + } else { + ruleUrl = `${this.props.feedUrl}/${this.props.rule.id}/`; + } return (
      @@ -66,7 +77,7 @@ class PostModal extends React.Component { {this.props.category && ( diff --git a/src/newsreader/js/pages/homepage/components/postlist/PostItem.js b/src/newsreader/js/pages/homepage/components/postlist/PostItem.js index 9b64289..f69a463 100644 --- a/src/newsreader/js/pages/homepage/components/postlist/PostItem.js +++ b/src/newsreader/js/pages/homepage/components/postlist/PostItem.js @@ -1,7 +1,13 @@ import React from 'react'; import { connect } from 'react-redux'; -import { CATEGORY_TYPE, RULE_TYPE, FEED, SUBREDDIT } from '../../constants.js'; +import { + CATEGORY_TYPE, + RULE_TYPE, + FEED, + SUBREDDIT, + TWITTER_TIMELINE, +} from '../../constants.js'; import { selectPost } from '../../actions/posts.js'; import { formatDatetime } from '../../../../utils.js'; @@ -13,11 +19,15 @@ class PostItem extends React.Component { const titleClassName = post.read ? 'posts__header posts__header--read' : 'posts__header'; + let ruleUrl = ''; - const ruleUrl = - rule.type === FEED - ? `/collection/rules/${rule.id}/` - : `/collection/rules/subreddits/${rule.id}/`; + if (rule.type === SUBREDDIT) { + ruleUrl = `${this.props.subredditUrl}/${rule.id}/`; + } else if (rule.type === TWITTER_TIMELINE) { + ruleUrl = `${this.props.timelineUrl}/${rule.id}/`; + } else { + ruleUrl = `${this.props.feedUrl}/${rule.id}/`; + } return (
    • diff --git a/src/newsreader/js/pages/homepage/components/postlist/PostList.js b/src/newsreader/js/pages/homepage/components/postlist/PostList.js index cd57d6d..cff2437 100644 --- a/src/newsreader/js/pages/homepage/components/postlist/PostList.js +++ b/src/newsreader/js/pages/homepage/components/postlist/PostList.js @@ -38,7 +38,16 @@ class PostList extends React.Component { render() { const postItems = this.props.postsBySection.map((item, index) => { - return ; + return ( + + ); }); if (isEqual(this.props.selected, {})) { diff --git a/src/newsreader/js/pages/homepage/constants.js b/src/newsreader/js/pages/homepage/constants.js index 66b6365..22184b9 100644 --- a/src/newsreader/js/pages/homepage/constants.js +++ b/src/newsreader/js/pages/homepage/constants.js @@ -3,3 +3,4 @@ export const CATEGORY_TYPE = 'CATEGORY'; export const SUBREDDIT = 'subreddit'; export const FEED = 'feed'; +export const TWITTER_TIMELINE = 'twitter_timeline'; diff --git a/src/newsreader/js/pages/homepage/index.js b/src/newsreader/js/pages/homepage/index.js index c16ed39..394a06c 100644 --- a/src/newsreader/js/pages/homepage/index.js +++ b/src/newsreader/js/pages/homepage/index.js @@ -11,9 +11,19 @@ const page = document.getElementById('homepage--page'); if (page) { const store = configureStore(); + let feedUrl = document.getElementById('feedUrl').textContent; + let subredditUrl = document.getElementById('subredditUrl').textContent; + let timelineUrl = document.getElementById('timelineUrl').textContent; + let categoriesUrl = document.getElementById('categoriesUrl').textContent; + ReactDOM.render( - + , page ); diff --git a/src/newsreader/news/collection/admin.py b/src/newsreader/news/collection/admin.py index c5a7c5c..ece5c23 100644 --- a/src/newsreader/news/collection/admin.py +++ b/src/newsreader/news/collection/admin.py @@ -6,14 +6,7 @@ from newsreader.news.collection.models import CollectionRule class CollectionRuleAdmin(admin.ModelAdmin): fields = ("url", "name", "timezone", "category", "favicon", "user") - list_display = ( - "name", - "type_display", - "category", - "url", - "last_suceeded", - "succeeded", - ) + list_display = ("name", "type_display", "category", "url", "last_run", "succeeded") list_filter = ("user",) def save_model(self, request, obj, form, change): diff --git a/src/newsreader/news/collection/base.py b/src/newsreader/news/collection/base.py index f980191..7286526 100644 --- a/src/newsreader/news/collection/base.py +++ b/src/newsreader/news/collection/base.py @@ -1,7 +1,10 @@ -from bs4 import BeautifulSoup +import bleach -from newsreader.news.collection.exceptions import StreamParseException -from newsreader.news.collection.utils import fetch +from newsreader.news.collection.constants import ( + WHITELISTED_ATTRIBUTES, + WHITELISTED_TAGS, +) +from newsreader.news.core.models import Post class Stream: @@ -20,19 +23,16 @@ class Stream: def parse(self, response): raise NotImplementedError - class Meta: - abstract = True - class Client: """ - Retrieves the data with streams + Retrieves the data through streams """ stream = Stream def __init__(self, rules=[]): - self.rules = rules if rules else CollectionRule.objects.enabled() + self.rules = rules def __enter__(self): for rule in self.rules: @@ -43,36 +43,40 @@ class Client: def __exit__(self, *args, **kwargs): pass - class Meta: - abstract = True - class Builder: """ - Creates the collected posts + Builds instances of various types """ instances = [] stream = None + payload = None - def __init__(self, stream): + def __init__(self, payload, stream): + self.payload = payload self.stream = stream def __enter__(self): - self.create_posts(self.stream) return self def __exit__(self, *args, **kwargs): pass - def create_posts(self, stream): - pass + def build(self): + raise NotImplementedError - def save(self): - pass + def sanitize_fragment(self, fragment): + if not fragment: + return "" - class Meta: - abstract = True + return bleach.clean( + fragment, + tags=WHITELISTED_TAGS, + attributes=WHITELISTED_ATTRIBUTES, + strip=True, + strip_comments=True, + ) class Collector: @@ -88,46 +92,54 @@ class Collector: self.builder = builder if builder else self.builder def collect(self, rules=None): - with self.client(rules=rules) as client: - for data, stream in client: - with self.builder((data, stream)) as builder: - builder.save() - - class Meta: - abstract = True + raise NotImplementedError -class WebsiteStream(Stream): - def __init__(self, url): - self.url = url +class Scheduler: + """ + Schedules rules according to certain ratelimitting + """ - def read(self): - response = fetch(self.url) - - return (self.parse(response.content), self) - - def parse(self, payload): - try: - return BeautifulSoup(payload, "lxml") - except TypeError: - raise StreamParseException("Could not parse given HTML") + def get_scheduled_rules(self): + raise NotImplementedError -class URLBuilder(Builder): +class PostBuilder(Builder): + rule_type = None + def __enter__(self): - return self + self.existing_posts = { + post.remote_identifier: post + for post in Post.objects.filter( + rule=self.stream.rule, rule__type=self.rule_type + ) + } - def build(self): - data, stream = self.stream - rule = stream.rule + return super().__enter__() - try: - url = data["feed"]["link"] - except (KeyError, TypeError): - url = None + def save(self): + for post in self.instances: + post.save() - if url: - rule.website_url = url - rule.save() - return rule, url +class PostStream(Stream): + rule_type = None + + +class PostClient(Client): + stream = PostStream + + def set_rule_error(self, rule, exception): + length = rule._meta.get_field("error").max_length + + rule.error = exception.message[-length:] + rule.succeeded = False + + +class PostCollector(Collector): + def collect(self, rules=[]): + with self.client(rules=rules) as client: + for payload, stream in client: + with self.builder(payload, stream) as builder: + builder.build() + builder.save() diff --git a/src/newsreader/news/collection/choices.py b/src/newsreader/news/collection/choices.py index 65f7ef5..612079c 100644 --- a/src/newsreader/news/collection/choices.py +++ b/src/newsreader/news/collection/choices.py @@ -5,3 +5,10 @@ from django.utils.translation import gettext as _ class RuleTypeChoices(TextChoices): feed = "feed", _("Feed") subreddit = "subreddit", _("Subreddit") + twitter_timeline = "twitter_timeline", _("Twitter timeline") + + +class TwitterPostTypeChoices(TextChoices): + photo = "photo", _("Photo") + video = "video", _("Video") + animated_gif = "animated_gif", _("GIF") diff --git a/src/newsreader/news/collection/constants.py b/src/newsreader/news/collection/constants.py index eade898..0c73642 100644 --- a/src/newsreader/news/collection/constants.py +++ b/src/newsreader/news/collection/constants.py @@ -23,6 +23,7 @@ WHITELISTED_TAGS = ( WHITELISTED_ATTRIBUTES = { **BLEACH_ATTRIBUTES, "a": ["href", "rel"], - "img": ["alt", "src"], - "source": ["srcset", "media", "src", "type"], + "img": ["alt", "src", "loading"], + "video": ["controls", "muted"], + "source": ["srcset", "src", "media", "type"], } diff --git a/src/newsreader/news/collection/favicon.py b/src/newsreader/news/collection/favicon.py index 44b96bf..639e7f6 100644 --- a/src/newsreader/news/collection/favicon.py +++ b/src/newsreader/news/collection/favicon.py @@ -1,16 +1,12 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from urllib.parse import urljoin, urlparse -from newsreader.news.collection.base import ( - Builder, - Client, - Collector, - Stream, - URLBuilder, - WebsiteStream, -) -from newsreader.news.collection.exceptions import StreamException +from bs4 import BeautifulSoup + +from newsreader.news.collection.base import Builder, Client, Collector, Stream +from newsreader.news.collection.exceptions import StreamException, StreamParseException from newsreader.news.collection.feed import FeedClient +from newsreader.news.collection.utils import fetch LINK_RELS = [ @@ -21,17 +17,45 @@ LINK_RELS = [ ] +class WebsiteStream(Stream): + def read(self): + response = fetch(self.rule.website_url) + + return self.parse(response.content), self + + def parse(self, payload): + try: + return BeautifulSoup(payload, features="lxml") + except TypeError: + raise StreamParseException("Could not parse given HTML") + + +class WebsiteURLBuilder(Builder): + def build(self): + try: + url = self.payload["feed"]["link"] + except (KeyError, TypeError): + url = None + + self.instances = [(self.stream, url)] if url else [] + + def save(self): + for stream, url in self.instances: + stream.rule.website_url = url + stream.rule.save() + + class FaviconBuilder(Builder): def build(self): - rule, soup = self.stream + rule = self.stream.rule - url = self.parse(soup, rule.website_url) + url = self.parse() - if url: - rule.favicon = url - rule.save() + self.instances = [(rule, url)] if url else [] + + def parse(self): + soup = self.payload - def parse(self, soup, website_url): if not soup.head: return @@ -44,9 +68,9 @@ class FaviconBuilder(Builder): parsed_url = urlparse(url) if not parsed_url.scheme and not parsed_url.netloc: - if not website_url: + if not self.stream.rule.website_url: return - return urljoin(website_url, url) + return urljoin(self.stream.rule.website_url, url) elif not parsed_url.scheme: return urljoin(f"https://{parsed_url.netloc}", parsed_url.path) @@ -73,6 +97,11 @@ class FaviconBuilder(Builder): elif icons: return icons.pop() + def save(self): + for rule, favicon_url in self.instances: + rule.favicon = favicon_url + rule.save() + class FaviconClient(Client): stream = WebsiteStream @@ -82,39 +111,35 @@ class FaviconClient(Client): def __enter__(self): with ThreadPoolExecutor(max_workers=10) as executor: - futures = { - executor.submit(stream.read): rule for rule, stream in self.streams - } + futures = [executor.submit(stream.read) for stream in self.streams] for future in as_completed(futures): - rule = futures[future] - try: - response_data, stream = future.result() + payload, stream = future.result() except StreamException: continue - yield (rule, response_data) + yield payload, stream class FaviconCollector(Collector): feed_client, favicon_client = (FeedClient, FaviconClient) - url_builder, favicon_builder = (URLBuilder, FaviconBuilder) + url_builder, favicon_builder = (WebsiteURLBuilder, FaviconBuilder) def collect(self, rules=None): streams = [] with self.feed_client(rules=rules) as client: - for data, stream in client: - with self.url_builder((data, stream)) as builder: - rule, url = builder.build() + for payload, stream in client: + with self.url_builder(payload, stream) as builder: + builder.build() + builder.save() - if not url: - continue - - streams.append((rule, WebsiteStream(url))) + if builder.instances: + streams.append(WebsiteStream(stream.rule)) with self.favicon_client(streams) as client: - for rule, data in client: - with self.favicon_builder((rule, data)) as builder: + for payload, stream in client: + with self.favicon_builder(payload, stream) as builder: builder.build() + builder.save() diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index f67a109..ae6cd42 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -6,17 +6,17 @@ from datetime import timedelta from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist from django.utils import timezone -import bleach import pytz from feedparser import parse -from newsreader.news.collection.base import Builder, Client, Collector, Stream -from newsreader.news.collection.choices import RuleTypeChoices -from newsreader.news.collection.constants import ( - WHITELISTED_ATTRIBUTES, - WHITELISTED_TAGS, +from newsreader.news.collection.base import ( + PostBuilder, + PostClient, + PostCollector, + PostStream, ) +from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.exceptions import ( StreamDeniedException, StreamException, @@ -24,7 +24,6 @@ from newsreader.news.collection.exceptions import ( StreamParseException, StreamTimeOutException, ) -from newsreader.news.collection.models import CollectionRule from newsreader.news.collection.utils import ( build_publication_date, fetch, @@ -36,32 +35,10 @@ from newsreader.news.core.models import Post logger = logging.getLogger(__name__) -class FeedBuilder(Builder): - instances = [] +class FeedBuilder(PostBuilder): + rule__type = RuleTypeChoices.feed - def __enter__(self): - _, stream = self.stream - - self.instances = [] - self.existing_posts = { - post.remote_identifier: post - for post in Post.objects.filter( - rule=stream.rule, rule__type=RuleTypeChoices.feed - ) - } - - return super().__enter__() - - def create_posts(self, stream): - data, stream = stream - - with FeedDuplicateHandler(stream.rule) as duplicate_handler: - entries = data.get("entries", []) - - instances = self.build(entries, stream.rule) - self.instances = duplicate_handler.check(instances) - - def build(self, entries, rule): + def build(self): field_mapping = { "id": "remote_identifier", "title": "title", @@ -70,56 +47,47 @@ class FeedBuilder(Builder): "published_parsed": "publication_date", "author": "author", } + tz = pytz.timezone(self.stream.rule.timezone) + instances = [] - tz = pytz.timezone(rule.timezone) + with FeedDuplicateHandler(self.stream.rule) as duplicate_handler: + entries = self.payload.get("entries", []) - for entry in entries: - data = {"rule_id": rule.pk} + for entry in entries: + data = {"rule_id": self.stream.rule.pk} - for field, model_field in field_mapping.items(): - if not field in entry: - continue + for field, model_field in field_mapping.items(): + if not field in entry: + continue - value = truncate_text(Post, model_field, entry[field]) + value = truncate_text(Post, model_field, entry[field]) - if field == "published_parsed": - data[model_field] = build_publication_date(value, tz) - elif field == "summary": - data[model_field] = self.sanitize_fragment(value) - else: - data[model_field] = value + if field == "published_parsed": + data[model_field] = build_publication_date(value, tz) + elif field == "summary": + data[model_field] = self.sanitize_fragment(value) + else: + data[model_field] = value - if "content" in entry: - content = self.get_content(entry["content"]) - body = data.get("body", "") + if "content" in entry: + content = self.get_content(entry["content"]) + body = data.get("body", "") - if not body or len(body) < len(content): - data["body"] = content + if not body or len(body) < len(content): + data["body"] = content - yield Post(**data) + instances.append(Post(**data)) - def sanitize_fragment(self, fragment): - if not fragment: - return "" - - return bleach.clean( - fragment, - tags=WHITELISTED_TAGS, - attributes=WHITELISTED_ATTRIBUTES, - strip=True, - strip_comments=True, - ) + self.instances = duplicate_handler.check(instances) def get_content(self, items): content = "\n ".join([item.get("value") for item in items]) return self.sanitize_fragment(content) - def save(self): - for post in self.instances: - post.save() +class FeedStream(PostStream): + rule_type = RuleTypeChoices.feed -class FeedStream(Stream): def read(self): response = fetch(self.rule.url) @@ -133,17 +101,9 @@ class FeedStream(Stream): raise StreamParseException(response=response, message=message) from e -class FeedClient(Client): +class FeedClient(PostClient): stream = FeedStream - def __init__(self, rules=[]): - if rules: - self.rules = rules - else: - self.rules = CollectionRule.objects.filter( - enabled=True, type=RuleTypeChoices.feed - ) - def __enter__(self): streams = [self.stream(rule) for rule in self.rules] @@ -154,13 +114,12 @@ class FeedClient(Client): stream = futures[future] try: - response_data = future.result() + payload = future.result() stream.rule.error = None stream.rule.succeeded = True - stream.rule.last_suceeded = timezone.now() - yield response_data + yield payload except (StreamNotFoundException, StreamTimeOutException) as e: logger.warning(f"Request failed for {stream.rule.url}") @@ -174,16 +133,11 @@ class FeedClient(Client): continue finally: + stream.rule.last_run = timezone.now() stream.rule.save() - def set_rule_error(self, rule, exception): - length = rule._meta.get_field("error").max_length - rule.error = exception.message[-length:] - rule.succeeded = False - - -class FeedCollector(Collector): +class FeedCollector(PostCollector): builder = FeedBuilder client = FeedClient diff --git a/src/newsreader/news/collection/forms.py b/src/newsreader/news/collection/forms.py deleted file mode 100644 index c79a867..0000000 --- a/src/newsreader/news/collection/forms.py +++ /dev/null @@ -1,101 +0,0 @@ -from django import forms -from django.core.exceptions import ValidationError -from django.utils.safestring import mark_safe -from django.utils.translation import gettext_lazy as _ - -import pytz - -from newsreader.core.forms import CheckboxInput -from newsreader.news.collection.choices import RuleTypeChoices -from newsreader.news.collection.models import CollectionRule -from newsreader.news.collection.reddit import REDDIT_API_URL -from newsreader.news.core.models import Category - - -def get_reddit_help_text(): - return mark_safe( - "Only subreddits are supported" - " see the 'listings' section in the reddit API docs." - " For example: https://oauth.reddit.com/r/aww" - ) - - -class CollectionRuleForm(forms.ModelForm): - category = forms.ModelChoiceField(required=False, queryset=Category.objects.all()) - timezone = forms.ChoiceField( - widget=forms.Select(attrs={"size": len(pytz.all_timezones)}), - choices=((timezone, timezone) for timezone in pytz.all_timezones), - help_text=_("The timezone which the feed uses"), - initial=pytz.utc, - ) - - def __init__(self, *args, **kwargs): - self.user = kwargs.pop("user") - - super().__init__(*args, **kwargs) - - self.fields["category"].queryset = Category.objects.filter(user=self.user) - - def save(self, commit=True): - instance = super().save(commit=False) - instance.user = self.user - - if commit: - instance.save() - self.save_m2m() - - return instance - - class Meta: - model = CollectionRule - fields = ("name", "url", "timezone", "favicon", "category") - - -class CollectionRuleBulkForm(forms.Form): - rules = forms.ModelMultipleChoiceField(queryset=CollectionRule.objects.none()) - - def __init__(self, user, *args, **kwargs): - self.user = user - - super().__init__(*args, **kwargs) - - self.fields["rules"].queryset = CollectionRule.objects.filter(user=user) - - -class SubRedditRuleForm(CollectionRuleForm): - url = forms.URLField(max_length=1024, help_text=get_reddit_help_text) - - timezone = None - - def clean_url(self): - url = self.cleaned_data["url"] - - if not url.startswith(REDDIT_API_URL): - raise ValidationError(_("This does not look like an Reddit API URL")) - - return url - - def save(self, commit=True): - instance = super().save(commit=False) - - instance.type = RuleTypeChoices.subreddit - instance.timezone = str(pytz.utc) - - if commit: - instance.save() - self.save_m2m() - - return instance - - class Meta: - model = CollectionRule - fields = ("name", "url", "favicon", "category") - - -class OPMLImportForm(forms.Form): - file = forms.FileField(allow_empty_file=False) - skip_existing = forms.BooleanField( - initial=False, required=False, widget=CheckboxInput - ) diff --git a/src/newsreader/news/collection/forms/__init__.py b/src/newsreader/news/collection/forms/__init__.py new file mode 100644 index 0000000..88a51c7 --- /dev/null +++ b/src/newsreader/news/collection/forms/__init__.py @@ -0,0 +1,4 @@ +from newsreader.news.collection.forms.feed import FeedForm, OPMLImportForm +from newsreader.news.collection.forms.reddit import SubRedditForm +from newsreader.news.collection.forms.rules import CollectionRuleBulkForm +from newsreader.news.collection.forms.twitter import TwitterTimelineForm diff --git a/src/newsreader/news/collection/forms/base.py b/src/newsreader/news/collection/forms/base.py new file mode 100644 index 0000000..da23659 --- /dev/null +++ b/src/newsreader/news/collection/forms/base.py @@ -0,0 +1,29 @@ +from django import forms + +from newsreader.news.collection.models import CollectionRule +from newsreader.news.core.models import Category + + +class CollectionRuleForm(forms.ModelForm): + category = forms.ModelChoiceField(required=False, queryset=Category.objects.all()) + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop("user") + + super().__init__(*args, **kwargs) + + self.fields["category"].queryset = Category.objects.filter(user=self.user) + + def save(self, commit=True): + instance = super().save(commit=False) + instance.user = self.user + + if commit: + instance.save() + self.save_m2m() + + return instance + + class Meta: + model = CollectionRule + fields = "__all__" diff --git a/src/newsreader/news/collection/forms/feed.py b/src/newsreader/news/collection/forms/feed.py new file mode 100644 index 0000000..4a22a2e --- /dev/null +++ b/src/newsreader/news/collection/forms/feed.py @@ -0,0 +1,28 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ + +import pytz + +from newsreader.core.forms import CheckboxInput +from newsreader.news.collection.forms.base import CollectionRuleForm +from newsreader.news.collection.models import CollectionRule + + +class FeedForm(CollectionRuleForm): + timezone = forms.ChoiceField( + widget=forms.Select(attrs={"size": len(pytz.all_timezones)}), + choices=((timezone, timezone) for timezone in pytz.all_timezones), + help_text=_("The timezone which the feed uses"), + initial=pytz.utc, + ) + + class Meta: + model = CollectionRule + fields = ("name", "url", "timezone", "favicon", "category") + + +class OPMLImportForm(forms.Form): + file = forms.FileField(allow_empty_file=False) + skip_existing = forms.BooleanField( + initial=False, required=False, widget=CheckboxInput + ) diff --git a/src/newsreader/news/collection/forms/reddit.py b/src/newsreader/news/collection/forms/reddit.py new file mode 100644 index 0000000..1744893 --- /dev/null +++ b/src/newsreader/news/collection/forms/reddit.py @@ -0,0 +1,49 @@ +from django import forms +from django.core.exceptions import ValidationError +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ + +import pytz + +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.forms.base import CollectionRuleForm +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.reddit import REDDIT_API_URL + + +def get_reddit_help_text(): + return mark_safe( + "Only subreddits are supported" + " see the 'listings' section in the reddit API docs." + " For example: https://oauth.reddit.com/r/aww" + ) + + +class SubRedditForm(CollectionRuleForm): + url = forms.URLField(max_length=1024, help_text=get_reddit_help_text) + + def clean_url(self): + url = self.cleaned_data["url"] + + if not url.startswith(REDDIT_API_URL): + raise ValidationError(_("This does not look like an Reddit API URL")) + + return url + + def save(self, commit=True): + instance = super().save(commit=False) + + instance.type = RuleTypeChoices.subreddit + instance.timezone = str(pytz.utc) + + if commit: + instance.save() + self.save_m2m() + + return instance + + class Meta: + model = CollectionRule + fields = ("name", "url", "favicon", "category") diff --git a/src/newsreader/news/collection/forms/rules.py b/src/newsreader/news/collection/forms/rules.py new file mode 100644 index 0000000..fade945 --- /dev/null +++ b/src/newsreader/news/collection/forms/rules.py @@ -0,0 +1,14 @@ +from django import forms + +from newsreader.news.collection.models import CollectionRule + + +class CollectionRuleBulkForm(forms.Form): + rules = forms.ModelMultipleChoiceField(queryset=CollectionRule.objects.none()) + + def __init__(self, user, *args, **kwargs): + self.user = user + + super().__init__(*args, **kwargs) + + self.fields["rules"].queryset = CollectionRule.objects.filter(user=user) diff --git a/src/newsreader/news/collection/forms/twitter.py b/src/newsreader/news/collection/forms/twitter.py new file mode 100644 index 0000000..902652b --- /dev/null +++ b/src/newsreader/news/collection/forms/twitter.py @@ -0,0 +1,35 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ + +import pytz + +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.forms.base import CollectionRuleForm +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.twitter import TWITTER_API_URL + + +class TwitterTimelineForm(CollectionRuleForm): + screen_name = forms.CharField( + max_length=255, + label=_("Twitter profile name"), + help_text=_("Profile name without hashtags"), + required=True, + ) + + def save(self, commit=True): + instance = super().save(commit=False) + + instance.type = RuleTypeChoices.twitter_timeline + instance.timezone = str(pytz.utc) + instance.url = f"{TWITTER_API_URL}/statuses/user_timeline.json?screen_name={instance.screen_name}&tweet_mode=extended" + + if commit: + instance.save() + self.save_m2m() + + return instance + + class Meta: + model = CollectionRule + fields = ("name", "screen_name", "favicon", "category") diff --git a/src/newsreader/news/collection/migrations/0009_auto_20200807_2030.py b/src/newsreader/news/collection/migrations/0009_auto_20200807_2030.py new file mode 100644 index 0000000..2ce4cb3 --- /dev/null +++ b/src/newsreader/news/collection/migrations/0009_auto_20200807_2030.py @@ -0,0 +1,29 @@ +# Generated by Django 3.0.7 on 2020-08-07 18:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("collection", "0008_collectionrule_type")] + + operations = [ + migrations.AddField( + model_name="collectionrule", + name="screen_name", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name="collectionrule", + name="type", + field=models.CharField( + choices=[ + ("feed", "Feed"), + ("subreddit", "Subreddit"), + ("twitter", "Twitter"), + ], + default="feed", + max_length=20, + ), + ), + ] diff --git a/src/newsreader/news/collection/migrations/0010_auto_20200913_2101.py b/src/newsreader/news/collection/migrations/0010_auto_20200913_2101.py new file mode 100644 index 0000000..2f08f6e --- /dev/null +++ b/src/newsreader/news/collection/migrations/0010_auto_20200913_2101.py @@ -0,0 +1,24 @@ +# Generated by Django 3.0.7 on 2020-09-13 19:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("collection", "0009_auto_20200807_2030")] + + operations = [ + migrations.AlterField( + model_name="collectionrule", + name="type", + field=models.CharField( + choices=[ + ("feed", "Feed"), + ("subreddit", "Subreddit"), + ("twitter_timeline", "Twitter timeline"), + ], + default="feed", + max_length=20, + ), + ) + ] diff --git a/src/newsreader/news/collection/migrations/0011_auto_20200913_2157.py b/src/newsreader/news/collection/migrations/0011_auto_20200913_2157.py new file mode 100644 index 0000000..308c654 --- /dev/null +++ b/src/newsreader/news/collection/migrations/0011_auto_20200913_2157.py @@ -0,0 +1,14 @@ +# Generated by Django 3.0.7 on 2020-09-13 19:57 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [("collection", "0010_auto_20200913_2101")] + + operations = [ + migrations.RenameField( + model_name="collectionrule", old_name="last_suceeded", new_name="last_run" + ) + ] diff --git a/src/newsreader/news/collection/models.py b/src/newsreader/news/collection/models.py index 35841ba..92dfe51 100644 --- a/src/newsreader/news/collection/models.py +++ b/src/newsreader/news/collection/models.py @@ -41,9 +41,8 @@ class CollectionRule(TimeStampedModel): on_delete=models.SET_NULL, ) - last_suceeded = models.DateTimeField(blank=True, null=True) + last_run = models.DateTimeField(blank=True, null=True) succeeded = models.BooleanField(default=False) - error = models.CharField(max_length=1024, blank=True, null=True) enabled = models.BooleanField( @@ -57,6 +56,9 @@ class CollectionRule(TimeStampedModel): on_delete=models.CASCADE, ) + # Twitter + screen_name = models.CharField(max_length=255, blank=True, null=True) + objects = CollectionRuleQuerySet.as_manager() def __str__(self): @@ -66,5 +68,13 @@ class CollectionRule(TimeStampedModel): def update_url(self): if self.type == RuleTypeChoices.subreddit: return reverse("news:collection:subreddit-update", kwargs={"pk": self.pk}) + elif self.type == RuleTypeChoices.twitter_timeline: + return reverse( + "news:collection:twitter-timeline-update", kwargs={"pk": self.pk} + ) - return reverse("news:collection:rule-update", kwargs={"pk": self.pk}) + return reverse("news:collection:feed-update", kwargs={"pk": self.pk}) + + @property + def failed(self): + return not self.succeeded and self.last_run diff --git a/src/newsreader/news/collection/reddit.py b/src/newsreader/news/collection/reddit.py index 557271c..daeb85f 100644 --- a/src/newsreader/news/collection/reddit.py +++ b/src/newsreader/news/collection/reddit.py @@ -12,11 +12,16 @@ from django.core.cache import cache from django.utils import timezone from django.utils.html import format_html -import bleach import pytz import requests -from newsreader.news.collection.base import Builder, Client, Collector, Stream +from newsreader.news.collection.base import ( + PostBuilder, + PostClient, + PostCollector, + PostStream, + Scheduler, +) from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.constants import ( WHITELISTED_ATTRIBUTES, @@ -93,32 +98,32 @@ def get_reddit_access_token(code, user): return response_data["access_token"], response_data["refresh_token"] -class RedditBuilder(Builder): - def __enter__(self): - _, stream = self.stream +# Note that the API always returns 204's with correct basic auth headers +def revoke_reddit_token(user): + client_auth = requests.auth.HTTPBasicAuth( + settings.REDDIT_CLIENT_ID, settings.REDDIT_CLIENT_SECRET + ) - self.instances = [] - self.existing_posts = { - post.remote_identifier: post - for post in Post.objects.filter( - rule=stream.rule, rule__type=RuleTypeChoices.subreddit - ) - } + response = post( + f"{REDDIT_URL}/api/v1/revoke_token", + data={"token": user.reddit_refresh_token, "token_type_hint": "refresh_token"}, + auth=client_auth, + ) - return super().__enter__() + return response.status_code == 204 - def create_posts(self, stream): - data, stream = stream - posts = [] - if not "data" in data or not "children" in data["data"]: +class RedditBuilder(PostBuilder): + rule_type = RuleTypeChoices.subreddit + + def build(self): + results = {} + + if not "data" in self.payload or not "children" in self.payload["data"]: return - posts = data["data"]["children"] - self.instances = self.build(posts, stream.rule) - - def build(self, posts, rule): - results = {} + posts = self.payload["data"]["children"] + rule = self.stream.rule for post in posts: if not "data" in post or post["kind"] != REDDIT_POST: @@ -139,17 +144,7 @@ class RedditBuilder(Builder): if is_text_post: uncleaned_body = data["selftext_html"] unescaped_body = unescape(uncleaned_body) if uncleaned_body else "" - body = ( - bleach.clean( - unescaped_body, - tags=WHITELISTED_TAGS, - attributes=WHITELISTED_ATTRIBUTES, - strip=True, - strip_comments=True, - ) - if unescaped_body - else "" - ) + body = self.sanitize_fragment(unescaped_body) if unescaped_body else "" elif direct_url.endswith(REDDIT_IMAGE_EXTENSIONS): body = format_html( "
      {title}
      ", @@ -192,7 +187,9 @@ class RedditBuilder(Builder): parsed_date = datetime.fromtimestamp(post["data"]["created_utc"]) created_date = pytz.utc.localize(parsed_date) except (OverflowError, OSError): - logging.warning(f"Failed parsing timestamp from {url_fragment}") + logging.warning( + f"Failed parsing timestamp from {REDDIT_URL}{post_url_fragment}" + ) created_date = timezone.now() post_data = { @@ -216,14 +213,98 @@ class RedditBuilder(Builder): results[remote_identifier] = Post(**post_data) - return results.values() - - def save(self): - for post in self.instances: - post.save() + self.instances = results.values() -class RedditScheduler: +class RedditStream(PostStream): + rule_type = RuleTypeChoices.subreddit + headers = {} + + def __init__(self, rule): + super().__init__(rule) + + self.headers = { + f"Authorization": f"bearer {self.rule.user.reddit_access_token}" + } + + def read(self): + response = fetch(self.rule.url, headers=self.headers) + + return self.parse(response), self + + def parse(self, response): + try: + return response.json() + except JSONDecodeError as e: + raise StreamParseException( + response=response, message="Failed parsing json" + ) from e + + +class RedditClient(PostClient): + stream = RedditStream + + def __enter__(self): + streams = [[self.stream(rule) for rule in batch] for batch in self.rules] + rate_limitted = False + + with ThreadPoolExecutor(max_workers=10) as executor: + for batch in streams: + futures = {executor.submit(stream.read): stream for stream in batch} + + if rate_limitted: + logger.warning("Aborting requests, ratelimit hit") + break + + for future in as_completed(futures): + stream = futures[future] + + try: + response_data = future.result() + + stream.rule.error = None + stream.rule.succeeded = True + + yield response_data + except StreamDeniedException as e: + logger.warning( + f"Access token expired for user {stream.rule.user.pk}" + ) + + stream.rule.user.reddit_access_token = None + stream.rule.user.save() + + self.set_rule_error(stream.rule, e) + + RedditTokenTask.delay(stream.rule.user.pk) + + break + except StreamTooManyException as e: + logger.exception("Ratelimit hit, aborting batched subreddits") + + self.set_rule_error(stream.rule, e) + + rate_limitted = True + break + except StreamException as e: + logger.exception( + f"Stream failed reading content from {stream.rule.url}" + ) + + self.set_rule_error(stream.rule, e) + + continue + finally: + stream.rule.last_run = timezone.now() + stream.rule.save() + + +class RedditCollector(PostCollector): + builder = RedditBuilder + client = RedditClient + + +class RedditScheduler(Scheduler): max_amount = RATE_LIMIT max_user_amount = RATE_LIMIT / 4 @@ -234,7 +315,7 @@ class RedditScheduler: user__reddit_access_token__isnull=False, user__reddit_refresh_token__isnull=False, enabled=True, - ).order_by("last_suceeded")[:200] + ).order_by("last_run")[:200] else: self.subreddits = subreddits @@ -263,100 +344,3 @@ class RedditScheduler: current_amount += 1 return list(rule_mapping.values()) - - -class RedditStream(Stream): - headers = {} - user = None - - def __init__(self, rule): - super().__init__(rule) - - self.user = self.rule.user - self.headers = { - f"Authorization": f"bearer {self.rule.user.reddit_access_token}" - } - - def read(self): - response = fetch(self.rule.url, headers=self.headers) - - return self.parse(response), self - - def parse(self, response): - try: - return response.json() - except JSONDecodeError as e: - raise StreamParseException( - response=response, message=f"Failed parsing json" - ) from e - - -class RedditClient(Client): - stream = RedditStream - - def __init__(self, rules=[]): - self.rules = rules - - def __enter__(self): - streams = [[self.stream(rule) for rule in batch] for batch in self.rules] - rate_limitted = False - - with ThreadPoolExecutor(max_workers=10) as executor: - for batch in streams: - futures = {executor.submit(stream.read): stream for stream in batch} - - if rate_limitted: - break - - for future in as_completed(futures): - stream = futures[future] - - try: - response_data = future.result() - - stream.rule.error = None - stream.rule.succeeded = True - stream.rule.last_suceeded = timezone.now() - - yield response_data - except StreamDeniedException as e: - logger.warning( - f"Access token expired for user {stream.user.pk}" - ) - - stream.rule.user.reddit_access_token = None - stream.rule.user.save() - - self.set_rule_error(stream.rule, e) - - RedditTokenTask.delay(stream.rule.user.pk) - - break - except StreamTooManyException as e: - logger.exception("Ratelimit hit, aborting batched subreddits") - - self.set_rule_error(stream.rule, e) - - rate_limitted = True - break - except StreamException as e: - logger.exception( - "Stream failed reading content from " f"{stream.rule.url}" - ) - - self.set_rule_error(stream.rule, e) - - continue - finally: - stream.rule.save() - - def set_rule_error(self, rule, exception): - length = rule._meta.get_field("error").max_length - - rule.error = exception.message[-length:] - rule.succeeded = False - - -class RedditCollector(Collector): - builder = RedditBuilder - client = RedditClient diff --git a/src/newsreader/news/collection/tasks.py b/src/newsreader/news/collection/tasks.py index a04c5f9..926b05b 100644 --- a/src/newsreader/news/collection/tasks.py +++ b/src/newsreader/news/collection/tasks.py @@ -114,6 +114,40 @@ class RedditTokenTask(app.Task): user.save() +class TwitterTimelineTask(app.Task): + name = "TwitterTimelineTask" + ignore_result = True + + def run(self, user_pk): + from newsreader.news.collection.twitter import ( + TwitterCollector, + TwitterTimeLineScheduler, + ) + + try: + user = User.objects.get(pk=user_pk) + except ObjectDoesNotExist: + message = f"User {user_pk} does not exist" + logger.exception(message) + + raise Reject(reason=message, requeue=False) + + with MemCacheLock("f{user.email}-timeline-task", self.app.oid) as acquired: + if acquired: + logger.info(f"Running twitter timeline task for user {user_pk}") + + scheduler = TwitterTimeLineScheduler(user) + timelines = scheduler.get_scheduled_rules() + + collector = TwitterCollector() + collector.collect(rules=timelines) + else: + logger.warning(f"Cancelling task due to existing lock") + + raise Reject(reason="Task already running", requeue=False) + + FeedTask = app.register_task(FeedTask()) RedditTask = app.register_task(RedditTask()) RedditTokenTask = app.register_task(RedditTokenTask()) +TwitterTimelineTask = app.register_task(TwitterTimelineTask()) diff --git a/src/newsreader/news/collection/templates/news/collection/views/rule-create.html b/src/newsreader/news/collection/templates/news/collection/views/feed-create.html similarity index 78% rename from src/newsreader/news/collection/templates/news/collection/views/rule-create.html rename to src/newsreader/news/collection/templates/news/collection/views/feed-create.html index 82ed6c5..c24791a 100644 --- a/src/newsreader/news/collection/templates/news/collection/views/rule-create.html +++ b/src/newsreader/news/collection/templates/news/collection/views/feed-create.html @@ -4,6 +4,6 @@ {% block content %}
      {% url "news:collection:rules" as cancel_url %} - {% include "components/form/form.html" with form=form title="Create rule" cancel_url=cancel_url confirm_text="Create rule" %} + {% include "components/form/form.html" with form=form title="Add a feed" cancel_url=cancel_url confirm_text="Add feed" %}
      {% endblock %} diff --git a/src/newsreader/news/collection/templates/news/collection/views/rule-update.html b/src/newsreader/news/collection/templates/news/collection/views/feed-update.html similarity index 72% rename from src/newsreader/news/collection/templates/news/collection/views/rule-update.html rename to src/newsreader/news/collection/templates/news/collection/views/feed-update.html index 0a705b8..33b1faf 100644 --- a/src/newsreader/news/collection/templates/news/collection/views/rule-update.html +++ b/src/newsreader/news/collection/templates/news/collection/views/feed-update.html @@ -3,12 +3,12 @@ {% block content %}
      - {% if rule.error %} + {% if feed.error %} {% trans "Failed to retrieve posts" as title %} - {% include "components/textbox/textbox.html" with title=title body=rule.error class="text-section--error" only %} + {% include "components/textbox/textbox.html" with title=title body=feed.error class="text-section--error" only %} {% endif %} {% url "news:collection:rules" as cancel_url %} - {% include "components/form/form.html" with form=form title="Update rule" cancel_url=cancel_url confirm_text="Save rule" only %} + {% include "components/form/form.html" with form=form title="Update feed" cancel_url=cancel_url confirm_text="Save feed" only %}
      {% endblock %} diff --git a/src/newsreader/news/collection/templates/news/collection/views/import.html b/src/newsreader/news/collection/templates/news/collection/views/import.html index df19887..9719847 100644 --- a/src/newsreader/news/collection/templates/news/collection/views/import.html +++ b/src/newsreader/news/collection/templates/news/collection/views/import.html @@ -4,6 +4,6 @@ {% block content %}
      {% url "news:collection:rules" as cancel_url %} - {% include "components/form/form.html" with form=form title="Import an OPML file" cancel_url=cancel_url confirm_text="Import rules" %} + {% include "components/form/form.html" with form=form title="Import an OPML file" cancel_url=cancel_url confirm_text="Import feeds" %}
      {% endblock %} diff --git a/src/newsreader/news/collection/templates/news/collection/views/rules.html b/src/newsreader/news/collection/templates/news/collection/views/rules.html index 0cd1870..678716e 100644 --- a/src/newsreader/news/collection/templates/news/collection/views/rules.html +++ b/src/newsreader/news/collection/templates/news/collection/views/rules.html @@ -14,8 +14,9 @@ @@ -36,7 +37,7 @@
    • {% with rule|id_for_label:"rules" as id_for_label %} {% include "components/form/checkbox.html" with name="rules" value=rule.pk id=id_for_label id_for_label=id_for_label %} @@ -54,10 +55,10 @@ {{ rule.url }} - {% if rule.succeeded %} - - {% else %} + {% if rule.failed %} + {% else %} + {% endif %} diff --git a/src/newsreader/news/collection/templates/news/collection/views/twitter/timeline-create.html b/src/newsreader/news/collection/templates/news/collection/views/twitter/timeline-create.html new file mode 100644 index 0000000..7c8eb13 --- /dev/null +++ b/src/newsreader/news/collection/templates/news/collection/views/twitter/timeline-create.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
      + {% url "news:collection:rules" as cancel_url %} + {% include "components/form/form.html" with form=form title="Add a Twitter profile" cancel_url=cancel_url confirm_text="Add profile" %} +
      +{% endblock %} diff --git a/src/newsreader/news/collection/templates/news/collection/views/twitter/timeline-update.html b/src/newsreader/news/collection/templates/news/collection/views/twitter/timeline-update.html new file mode 100644 index 0000000..51de47a --- /dev/null +++ b/src/newsreader/news/collection/templates/news/collection/views/twitter/timeline-update.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% load static i18n %} + +{% block content %} +
      + {% if timeline.error %} + {% trans "Failed to retrieve posts" as title %} + {% include "components/textbox/textbox.html" with title=title body=timeline.error class="text-section--error" only %} + {% endif %} + + {% url "news:collection:rules" as cancel_url %} + {% include "components/form/form.html" with form=form title="Update profile" cancel_url=cancel_url confirm_text="Save profile" %} +
      +{% endblock %} diff --git a/src/newsreader/news/collection/tests/factories.py b/src/newsreader/news/collection/tests/factories.py index fdf786f..26f66cc 100644 --- a/src/newsreader/news/collection/tests/factories.py +++ b/src/newsreader/news/collection/tests/factories.py @@ -28,3 +28,8 @@ class FeedFactory(CollectionRuleFactory): class SubredditFactory(CollectionRuleFactory): type = RuleTypeChoices.subreddit website_url = REDDIT_URL + + +class TwitterTimelineFactory(CollectionRuleFactory): + type = RuleTypeChoices.twitter_timeline + screen_name = factory.Faker("user_name") diff --git a/src/newsreader/news/collection/tests/favicon/builder/tests.py b/src/newsreader/news/collection/tests/favicon/builder/tests.py index e8a1a34..d21f77e 100644 --- a/src/newsreader/news/collection/tests/favicon/builder/tests.py +++ b/src/newsreader/news/collection/tests/favicon/builder/tests.py @@ -1,3 +1,5 @@ +from unittest.mock import Mock + from django.test import TestCase from newsreader.news.collection.favicon import FaviconBuilder @@ -12,8 +14,11 @@ class FaviconBuilderTestCase(TestCase): def test_simple(self): rule = CollectionRuleFactory(favicon=None) - with FaviconBuilder((rule, simple_mock)) as builder: + with FaviconBuilder(simple_mock, Mock(rule=rule)) as builder: builder.build() + builder.save() + + rule.refresh_from_db() self.assertEquals(rule.favicon, "https://www.bbc.com/favicon.ico") @@ -22,24 +27,33 @@ class FaviconBuilderTestCase(TestCase): website_url="https://www.theguardian.com/", favicon=None ) - with FaviconBuilder((rule, mock_without_url)) as builder: + with FaviconBuilder(mock_without_url, Mock(rule=rule)) as builder: builder.build() + builder.save() + + rule.refresh_from_db() self.assertEquals(rule.favicon, "https://www.theguardian.com/favicon.ico") def test_without_header(self): rule = CollectionRuleFactory(favicon=None) - with FaviconBuilder((rule, mock_without_header)) as builder: + with FaviconBuilder(mock_without_header, Mock(rule=rule)) as builder: builder.build() + builder.save() + + rule.refresh_from_db() self.assertEquals(rule.favicon, None) def test_weird_path(self): rule = CollectionRuleFactory(favicon=None) - with FaviconBuilder((rule, mock_with_weird_path)) as builder: + with FaviconBuilder(mock_with_weird_path, Mock(rule=rule)) as builder: builder.build() + builder.save() + + rule.refresh_from_db() self.assertEquals( rule.favicon, "https://www.theguardian.com/jabadaba/doe/favicon.ico" @@ -48,15 +62,21 @@ class FaviconBuilderTestCase(TestCase): def test_other_url(self): rule = CollectionRuleFactory(favicon=None) - with FaviconBuilder((rule, mock_with_other_url)) as builder: + with FaviconBuilder(mock_with_other_url, Mock(rule=rule)) as builder: builder.build() + builder.save() + + rule.refresh_from_db() self.assertEquals(rule.favicon, "https://www.theguardian.com/icon.png") def test_url_with_favicon_takes_precedence(self): rule = CollectionRuleFactory(favicon=None) - with FaviconBuilder((rule, mock_with_multiple_icons)) as builder: + with FaviconBuilder(mock_with_multiple_icons, Mock(rule=rule)) as builder: builder.build() + builder.save() + + rule.refresh_from_db() self.assertEquals(rule.favicon, "https://www.bbc.com/favicon.ico") diff --git a/src/newsreader/news/collection/tests/favicon/client/tests.py b/src/newsreader/news/collection/tests/favicon/client/tests.py index 717ee0c..85b8fa3 100644 --- a/src/newsreader/news/collection/tests/favicon/client/tests.py +++ b/src/newsreader/news/collection/tests/favicon/client/tests.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock +from unittest.mock import Mock from django.test import TestCase @@ -19,22 +19,22 @@ class FaviconClientTestCase(TestCase): def test_simple(self): rule = CollectionRuleFactory() - stream = MagicMock(url="https://www.bbc.com") + stream = Mock(url="https://www.bbc.com", rule=rule) stream.read.return_value = (simple_mock, stream) - with FaviconClient([(rule, stream)]) as client: - for rule, data in client: - self.assertEquals(rule.pk, rule.pk) - self.assertEquals(data, simple_mock) + with FaviconClient([stream]) as client: + for payload, stream in client: + self.assertEquals(stream.rule.pk, rule.pk) + self.assertEquals(payload, simple_mock) stream.read.assert_called_once_with() def test_client_catches_stream_exception(self): rule = CollectionRuleFactory(error=None, succeeded=True) - stream = MagicMock(url="https://www.bbc.com") + stream = Mock(url="https://www.bbc.com", rule=rule) stream.read.side_effect = StreamException - with FaviconClient([(rule, stream)]) as client: + with FaviconClient([stream]) as client: for rule, data in client: pass @@ -46,10 +46,10 @@ class FaviconClientTestCase(TestCase): def test_client_catches_stream_not_found_exception(self): rule = CollectionRuleFactory(error=None, succeeded=True) - stream = MagicMock(url="https://www.bbc.com") + stream = Mock(url="https://www.bbc.com", rule=rule) stream.read.side_effect = StreamNotFoundException - with FaviconClient([(rule, stream)]) as client: + with FaviconClient([stream]) as client: for rule, data in client: pass @@ -61,10 +61,10 @@ class FaviconClientTestCase(TestCase): def test_client_catches_stream_denied_exception(self): rule = CollectionRuleFactory(error=None, succeeded=True) - stream = MagicMock(url="https://www.bbc.com") + stream = Mock(url="https://www.bbc.com", rule=rule) stream.read.side_effect = StreamDeniedException - with FaviconClient([(rule, stream)]) as client: + with FaviconClient([stream]) as client: for rule, data in client: pass @@ -76,10 +76,10 @@ class FaviconClientTestCase(TestCase): def test_client_catches_stream_timed_out(self): rule = CollectionRuleFactory(error=None, succeeded=True) - stream = MagicMock(url="https://www.bbc.com") + stream = Mock(url="https://www.bbc.com", rule=rule) stream.read.side_effect = StreamTimeOutException - with FaviconClient([(rule, stream)]) as client: + with FaviconClient([stream]) as client: for rule, data in client: pass diff --git a/src/newsreader/news/collection/tests/favicon/collector/tests.py b/src/newsreader/news/collection/tests/favicon/collector/tests.py index 44254a5..cb73a7c 100644 --- a/src/newsreader/news/collection/tests/favicon/collector/tests.py +++ b/src/newsreader/news/collection/tests/favicon/collector/tests.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import Mock, patch from django.test import TestCase @@ -38,8 +38,8 @@ class FaviconCollectorTestCase(TestCase): def test_simple(self): rule = CollectionRuleFactory(succeeded=True, error=None) - self.mocked_feed_client.return_value = [(feed_mock, MagicMock(rule=rule))] - self.mocked_website_read.return_value = (website_mock, MagicMock()) + self.mocked_feed_client.return_value = [(feed_mock, Mock(rule=rule))] + self.mocked_website_read.return_value = (website_mock, Mock(rule=rule)) collector = FaviconCollector() collector.collect() @@ -54,8 +54,11 @@ class FaviconCollectorTestCase(TestCase): def test_empty_stream(self): rule = CollectionRuleFactory(succeeded=True, error=None) - self.mocked_feed_client.return_value = [(feed_mock, MagicMock(rule=rule))] - self.mocked_website_read.return_value = (BeautifulSoup("", "lxml"), MagicMock()) + self.mocked_feed_client.return_value = [(feed_mock, Mock(rule=rule))] + self.mocked_website_read.return_value = ( + BeautifulSoup("", "lxml"), + Mock(rule=rule), + ) collector = FaviconCollector() collector.collect() @@ -70,7 +73,7 @@ class FaviconCollectorTestCase(TestCase): def test_not_found(self): rule = CollectionRuleFactory(succeeded=True, error=None) - self.mocked_feed_client.return_value = [(feed_mock, MagicMock(rule=rule))] + self.mocked_feed_client.return_value = [(feed_mock, Mock(rule=rule))] self.mocked_website_read.side_effect = StreamNotFoundException collector = FaviconCollector() @@ -86,7 +89,7 @@ class FaviconCollectorTestCase(TestCase): def test_denied(self): rule = CollectionRuleFactory(succeeded=True, error=None) - self.mocked_feed_client.return_value = [(feed_mock, MagicMock(rule=rule))] + self.mocked_feed_client.return_value = [(feed_mock, Mock(rule=rule))] self.mocked_website_read.side_effect = StreamDeniedException collector = FaviconCollector() @@ -102,7 +105,7 @@ class FaviconCollectorTestCase(TestCase): def test_forbidden(self): rule = CollectionRuleFactory(succeeded=True, error=None) - self.mocked_feed_client.return_value = [(feed_mock, MagicMock(rule=rule))] + self.mocked_feed_client.return_value = [(feed_mock, Mock(rule=rule))] self.mocked_website_read.side_effect = StreamForbiddenException collector = FaviconCollector() @@ -118,7 +121,7 @@ class FaviconCollectorTestCase(TestCase): def test_timed_out(self): rule = CollectionRuleFactory(succeeded=True, error=None) - self.mocked_feed_client.return_value = [(feed_mock, MagicMock(rule=rule))] + self.mocked_feed_client.return_value = [(feed_mock, Mock(rule=rule))] self.mocked_website_read.side_effect = StreamTimeOutException collector = FaviconCollector() @@ -134,7 +137,7 @@ class FaviconCollectorTestCase(TestCase): def test_wrong_stream_content_type(self): rule = CollectionRuleFactory(succeeded=True, error=None) - self.mocked_feed_client.return_value = [(feed_mock, MagicMock(rule=rule))] + self.mocked_feed_client.return_value = [(feed_mock, Mock(rule=rule))] self.mocked_website_read.side_effect = StreamParseException collector = FaviconCollector() diff --git a/src/newsreader/news/collection/tests/feed/builder/tests.py b/src/newsreader/news/collection/tests/feed/builder/tests.py index 4a6eb69..571a7cd 100644 --- a/src/newsreader/news/collection/tests/feed/builder/tests.py +++ b/src/newsreader/news/collection/tests/feed/builder/tests.py @@ -1,5 +1,5 @@ from datetime import date, datetime, time -from unittest.mock import MagicMock +from unittest.mock import Mock from django.test import TestCase from django.utils import timezone @@ -24,9 +24,10 @@ class FeedBuilderTestCase(TestCase): def test_basic_entry(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((simple_mock, mock_stream)) as builder: + with builder(simple_mock, mock_stream) as builder: + builder.build() builder.save() post = Post.objects.get() @@ -55,9 +56,10 @@ class FeedBuilderTestCase(TestCase): def test_multiple_entries(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((multiple_mock, mock_stream)) as builder: + with builder(multiple_mock, mock_stream) as builder: + builder.build() builder.save() posts = Post.objects.order_by("-publication_date") @@ -116,9 +118,10 @@ class FeedBuilderTestCase(TestCase): def test_entries_without_remote_identifier(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_without_identifier, mock_stream)) as builder: + with builder(mock_without_identifier, mock_stream) as builder: + builder.build() builder.save() posts = Post.objects.order_by("-publication_date") @@ -155,9 +158,10 @@ class FeedBuilderTestCase(TestCase): def test_entry_without_publication_date(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_without_publish_date, mock_stream)) as builder: + with builder(mock_without_publish_date, mock_stream) as builder: + builder.build() builder.save() posts = Post.objects.order_by("-publication_date") @@ -187,9 +191,10 @@ class FeedBuilderTestCase(TestCase): def test_entry_without_url(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_without_url, mock_stream)) as builder: + with builder(mock_without_url, mock_stream) as builder: + builder.build() builder.save() posts = Post.objects.order_by("-publication_date") @@ -213,9 +218,10 @@ class FeedBuilderTestCase(TestCase): def test_entry_without_body(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_without_body, mock_stream)) as builder: + with builder(mock_without_body, mock_stream) as builder: + builder.build() builder.save() posts = Post.objects.order_by("-publication_date") @@ -247,9 +253,10 @@ class FeedBuilderTestCase(TestCase): def test_entry_without_author(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_without_author, mock_stream)) as builder: + with builder(mock_without_author, mock_stream) as builder: + builder.build() builder.save() posts = Post.objects.order_by("-publication_date") @@ -275,9 +282,10 @@ class FeedBuilderTestCase(TestCase): def test_empty_entries(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_without_entries, mock_stream)) as builder: + with builder(mock_without_entries, mock_stream) as builder: + builder.build() builder.save() self.assertEquals(Post.objects.count(), 0) @@ -285,7 +293,7 @@ class FeedBuilderTestCase(TestCase): def test_update_entries(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) existing_first_post = FeedPostFactory.create( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", rule=rule @@ -295,7 +303,8 @@ class FeedBuilderTestCase(TestCase): remote_identifier="a5479c66-8fae-11e9-8422-00163ef6bee7", rule=rule ) - with builder((mock_with_update_entries, mock_stream)) as builder: + with builder(mock_with_update_entries, mock_stream) as builder: + builder.build() builder.save() self.assertEquals(Post.objects.count(), 3) @@ -315,9 +324,10 @@ class FeedBuilderTestCase(TestCase): def test_html_sanitizing(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_with_html, mock_stream)) as builder: + with builder(mock_with_html, mock_stream) as builder: + builder.build() builder.save() post = Post.objects.get() @@ -337,9 +347,10 @@ class FeedBuilderTestCase(TestCase): def test_long_author_text_is_truncated(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_with_long_author, mock_stream)) as builder: + with builder(mock_with_long_author, mock_stream) as builder: + builder.build() builder.save() post = Post.objects.get() @@ -351,9 +362,10 @@ class FeedBuilderTestCase(TestCase): def test_long_title_text_is_truncated(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_with_long_title, mock_stream)) as builder: + with builder(mock_with_long_title, mock_stream) as builder: + builder.build() builder.save() post = Post.objects.get() @@ -366,9 +378,10 @@ class FeedBuilderTestCase(TestCase): def test_long_title_exotic_title(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_with_long_exotic_title, mock_stream)) as builder: + with builder(mock_with_long_exotic_title, mock_stream) as builder: + builder.build() builder.save() post = Post.objects.get() @@ -381,9 +394,10 @@ class FeedBuilderTestCase(TestCase): def test_content_detail_is_prioritized_if_longer(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_with_longer_content_detail, mock_stream)) as builder: + with builder(mock_with_longer_content_detail, mock_stream) as builder: + builder.build() builder.save() post = Post.objects.get() @@ -398,9 +412,10 @@ class FeedBuilderTestCase(TestCase): def test_content_detail_is_not_prioritized_if_shorter(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_with_shorter_content_detail, mock_stream)) as builder: + with builder(mock_with_shorter_content_detail, mock_stream) as builder: + builder.build() builder.save() post = Post.objects.get() @@ -414,9 +429,10 @@ class FeedBuilderTestCase(TestCase): def test_content_detail_is_concatinated(self): builder = FeedBuilder rule = FeedFactory() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) - with builder((mock_with_multiple_content_detail, mock_stream)) as builder: + with builder(mock_with_multiple_content_detail, mock_stream) as builder: + builder.build() builder.save() post = Post.objects.get() diff --git a/src/newsreader/news/collection/tests/feed/client/tests.py b/src/newsreader/news/collection/tests/feed/client/tests.py index 24eb214..9a2365e 100644 --- a/src/newsreader/news/collection/tests/feed/client/tests.py +++ b/src/newsreader/news/collection/tests/feed/client/tests.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import Mock, patch from django.test import TestCase from django.utils.lorem_ipsum import words @@ -28,7 +28,7 @@ class FeedClientTestCase(TestCase): def test_client_retrieves_single_rules(self): rule = FeedFactory.create() - mock_stream = MagicMock(rule=rule) + mock_stream = Mock(rule=rule) self.mocked_read.return_value = (simple_mock, mock_stream) diff --git a/src/newsreader/news/collection/tests/feed/collector/tests.py b/src/newsreader/news/collection/tests/feed/collector/tests.py index 5a1bac1..a7f3573 100644 --- a/src/newsreader/news/collection/tests/feed/collector/tests.py +++ b/src/newsreader/news/collection/tests/feed/collector/tests.py @@ -1,6 +1,6 @@ from datetime import date, datetime, time from time import struct_time -from unittest.mock import MagicMock, patch +from unittest.mock import Mock, patch from django.test import TestCase from django.utils import timezone @@ -26,6 +26,7 @@ from newsreader.news.core.tests.factories import FeedPostFactory from .mocks import duplicate_mock, empty_mock, multiple_mock, multiple_update_mock +@freeze_time("2019-10-30 12:30:00") class FeedCollectorTestCase(TestCase): def setUp(self): self.maxDiff = None @@ -39,43 +40,42 @@ class FeedCollectorTestCase(TestCase): def tearDown(self): patch.stopall() - @freeze_time("2019-10-30 12:30:00") def test_simple_batch(self): self.mocked_parse.return_value = multiple_mock - rule = FeedFactory() + rule = FeedFactory() collector = FeedCollector() - collector.collect() + collector.collect(rules=[rule]) rule.refresh_from_db() self.assertEquals(Post.objects.count(), 3) self.assertEquals(rule.succeeded, True) - self.assertEquals(rule.last_suceeded, timezone.now()) + self.assertEquals(rule.last_run, timezone.now()) self.assertEquals(rule.error, None) - @freeze_time("2019-10-30 12:30:00") def test_emtpy_batch(self): - self.mocked_fetch.return_value = MagicMock() + self.mocked_fetch.return_value = Mock() self.mocked_parse.return_value = empty_mock + rule = FeedFactory() collector = FeedCollector() - collector.collect() + collector.collect(rules=[rule]) rule.refresh_from_db() self.assertEquals(Post.objects.count(), 0) self.assertEquals(rule.succeeded, True) self.assertEquals(rule.error, None) - self.assertEquals(rule.last_suceeded, timezone.now()) + self.assertEquals(rule.last_run, timezone.now()) def test_not_found(self): self.mocked_fetch.side_effect = StreamNotFoundException - rule = FeedFactory() + rule = FeedFactory() collector = FeedCollector() - collector.collect() + collector.collect(rules=[rule]) rule.refresh_from_db() @@ -85,58 +85,59 @@ class FeedCollectorTestCase(TestCase): def test_denied(self): self.mocked_fetch.side_effect = StreamDeniedException - last_suceeded = timezone.make_aware( - datetime.combine(date=date(2019, 10, 30), time=time(12, 30)) - ) - rule = FeedFactory(last_suceeded=last_suceeded) + + old_run = timezone.make_aware(datetime(2019, 10, 30, 12, 30)) + rule = FeedFactory(last_run=old_run) collector = FeedCollector() - collector.collect() + collector.collect(rules=[rule]) rule.refresh_from_db() self.assertEquals(Post.objects.count(), 0) self.assertEquals(rule.succeeded, False) self.assertEquals(rule.error, "Stream does not have sufficient permissions") - self.assertEquals(rule.last_suceeded, last_suceeded) + self.assertEquals(rule.last_run, timezone.now()) def test_forbidden(self): self.mocked_fetch.side_effect = StreamForbiddenException - last_suceeded = timezone.make_aware( - datetime.combine(date=date(2019, 10, 30), time=time(12, 30)) - ) - rule = FeedFactory(last_suceeded=last_suceeded) + + old_run = pytz.utc.localize(datetime(2019, 10, 30, 12, 30)) + rule = FeedFactory(last_run=old_run) collector = FeedCollector() - collector.collect() + collector.collect(rules=[rule]) rule.refresh_from_db() self.assertEquals(Post.objects.count(), 0) self.assertEquals(rule.succeeded, False) self.assertEquals(rule.error, "Stream forbidden") - self.assertEquals(rule.last_suceeded, last_suceeded) + self.assertEquals(rule.last_run, timezone.now()) def test_timed_out(self): self.mocked_fetch.side_effect = StreamTimeOutException - last_suceeded = timezone.make_aware( + + last_run = timezone.make_aware( datetime.combine(date=date(2019, 10, 30), time=time(12, 30)) ) - rule = FeedFactory(last_suceeded=last_suceeded) + rule = FeedFactory(last_run=last_run) collector = FeedCollector() - collector.collect() + collector.collect(rules=[rule]) rule.refresh_from_db() self.assertEquals(Post.objects.count(), 0) self.assertEquals(rule.succeeded, False) self.assertEquals(rule.error, "Stream timed out") - self.assertEquals(rule.last_suceeded, last_suceeded) + self.assertEquals( + rule.last_run, pytz.utc.localize(datetime(2019, 10, 30, 12, 30)) + ) - @freeze_time("2019-10-30 12:30:00") def test_duplicates(self): self.mocked_parse.return_value = duplicate_mock + rule = FeedFactory() aware_datetime = build_publication_date( @@ -186,10 +187,9 @@ class FeedCollectorTestCase(TestCase): self.assertEquals(Post.objects.count(), 3) self.assertEquals(rule.succeeded, True) - self.assertEquals(rule.last_suceeded, timezone.now()) + self.assertEquals(rule.last_run, timezone.now()) self.assertEquals(rule.error, None) - @freeze_time("2019-02-22 12:30:00") def test_items_with_identifiers_get_updated(self): self.mocked_parse.return_value = multiple_update_mock rule = FeedFactory() @@ -231,7 +231,7 @@ class FeedCollectorTestCase(TestCase): self.assertEquals(Post.objects.count(), 3) self.assertEquals(rule.succeeded, True) - self.assertEquals(rule.last_suceeded, timezone.now()) + self.assertEquals(rule.last_run, timezone.now()) self.assertEquals(rule.error, None) self.assertEquals( @@ -245,23 +245,3 @@ class FeedCollectorTestCase(TestCase): self.assertEquals( third_post.title, "Birmingham head teacher threatened over LGBT lessons" ) - - @freeze_time("2019-02-22 12:30:00") - def test_disabled_rules(self): - rules = (FeedFactory(enabled=False), FeedFactory(enabled=True)) - - self.mocked_parse.return_value = multiple_mock - - collector = FeedCollector() - collector.collect() - - for rule in rules: - rule.refresh_from_db() - - self.assertEquals(Post.objects.count(), 3) - self.assertEquals(rules[1].succeeded, True) - self.assertEquals(rules[1].last_suceeded, timezone.now()) - self.assertEquals(rules[1].error, None) - - self.assertEquals(rules[0].last_suceeded, None) - self.assertEquals(rules[0].succeeded, False) diff --git a/src/newsreader/news/collection/tests/feed/stream/tests.py b/src/newsreader/news/collection/tests/feed/stream/tests.py index 82a09a3..f827c15 100644 --- a/src/newsreader/news/collection/tests/feed/stream/tests.py +++ b/src/newsreader/news/collection/tests/feed/stream/tests.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import Mock, patch from django.test import TestCase @@ -27,7 +27,7 @@ class FeedStreamTestCase(TestCase): patch.stopall() def test_simple_stream(self): - self.mocked_fetch.return_value = MagicMock(content=simple_mock) + self.mocked_fetch.return_value = Mock(content=simple_mock) rule = FeedFactory() stream = FeedStream(rule) @@ -95,7 +95,7 @@ class FeedStreamTestCase(TestCase): @patch("newsreader.news.collection.feed.parse") def test_stream_raises_parse_exception(self, mocked_parse): - self.mocked_fetch.return_value = MagicMock() + self.mocked_fetch.return_value = Mock() mocked_parse.side_effect = TypeError rule = FeedFactory() diff --git a/src/newsreader/news/collection/tests/reddit/builder/tests.py b/src/newsreader/news/collection/tests/reddit/builder/tests.py index 9c1a046..11cf549 100644 --- a/src/newsreader/news/collection/tests/reddit/builder/tests.py +++ b/src/newsreader/news/collection/tests/reddit/builder/tests.py @@ -1,5 +1,5 @@ from datetime import datetime -from unittest.mock import MagicMock +from unittest.mock import Mock from django.test import TestCase @@ -20,9 +20,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((simple_mock, mock_stream)) as builder: + with builder(simple_mock, mock_stream) as builder: + builder.build() builder.save() posts = {post.remote_identifier: post for post in Post.objects.all()} @@ -65,9 +66,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((empty_mock, mock_stream)) as builder: + with builder(empty_mock, mock_stream) as builder: + builder.build() builder.save() self.assertEquals(Post.objects.count(), 0) @@ -76,9 +78,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((unknown_mock, mock_stream)) as builder: + with builder(unknown_mock, mock_stream) as builder: + builder.build() builder.save() self.assertEquals(Post.objects.count(), 0) @@ -95,9 +98,10 @@ class RedditBuilderTestCase(TestCase): ) builder = RedditBuilder - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((simple_mock, mock_stream)) as builder: + with builder(simple_mock, mock_stream) as builder: + builder.build() builder.save() posts = {post.remote_identifier: post for post in Post.objects.all()} @@ -132,9 +136,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((unsanitized_mock, mock_stream)) as builder: + with builder(unsanitized_mock, mock_stream) as builder: + builder.build() builder.save() posts = {post.remote_identifier: post for post in Post.objects.all()} @@ -149,9 +154,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((author_mock, mock_stream)) as builder: + with builder(author_mock, mock_stream) as builder: + builder.build() builder.save() posts = {post.remote_identifier: post for post in Post.objects.all()} @@ -166,9 +172,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((title_mock, mock_stream)) as builder: + with builder(title_mock, mock_stream) as builder: + builder.build() builder.save() posts = {post.remote_identifier: post for post in Post.objects.all()} @@ -186,9 +193,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((duplicate_mock, mock_stream)) as builder: + with builder(duplicate_mock, mock_stream) as builder: + builder.build() builder.save() posts = {post.remote_identifier: post for post in Post.objects.all()} @@ -200,13 +208,14 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) duplicate_post = RedditPostFactory( remote_identifier="hm0qct", rule=subreddit, title="foo" ) - with builder((simple_mock, mock_stream)) as builder: + with builder(simple_mock, mock_stream) as builder: + builder.build() builder.save() posts = {post.remote_identifier: post for post in Post.objects.all()} @@ -231,9 +240,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((image_mock, mock_stream)) as builder: + with builder(image_mock, mock_stream) as builder: + builder.build() builder.save() posts = {post.remote_identifier: post for post in Post.objects.all()} @@ -262,9 +272,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((external_image_mock, mock_stream)) as builder: + with builder(external_image_mock, mock_stream) as builder: + builder.build() builder.save() posts = {post.remote_identifier: post for post in Post.objects.all()} @@ -302,9 +313,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((video_mock, mock_stream)) as builder: + with builder(video_mock, mock_stream) as builder: + builder.build() builder.save() posts = {post.remote_identifier: post for post in Post.objects.all()} @@ -328,9 +340,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((external_video_mock, mock_stream)) as builder: + with builder(external_video_mock, mock_stream) as builder: + builder.build() builder.save() post = Post.objects.get() @@ -354,9 +367,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((external_gifv_mock, mock_stream)) as builder: + with builder(external_gifv_mock, mock_stream) as builder: + builder.build() builder.save() post = Post.objects.get() @@ -376,9 +390,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((simple_mock, mock_stream)) as builder: + with builder(simple_mock, mock_stream) as builder: + builder.build() builder.save() post = Post.objects.get(remote_identifier="hngsj8") @@ -400,9 +415,10 @@ class RedditBuilderTestCase(TestCase): builder = RedditBuilder subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) - with builder((unknown_mock, mock_stream)) as builder: + with builder(unknown_mock, mock_stream) as builder: + builder.build() builder.save() self.assertEquals(Post.objects.count(), 0) diff --git a/src/newsreader/news/collection/tests/reddit/client/tests.py b/src/newsreader/news/collection/tests/reddit/client/tests.py index f2ee84d..4dcc10f 100644 --- a/src/newsreader/news/collection/tests/reddit/client/tests.py +++ b/src/newsreader/news/collection/tests/reddit/client/tests.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import Mock, patch from uuid import uuid4 from django.test import TestCase @@ -31,7 +31,7 @@ class RedditClientTestCase(TestCase): def test_client_retrieves_single_rules(self): subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) self.mocked_read.return_value = (simple_mock, mock_stream) @@ -150,7 +150,7 @@ class RedditClientTestCase(TestCase): def test_client_catches_long_exception_text(self): subreddit = SubredditFactory() - mock_stream = MagicMock(rule=subreddit) + mock_stream = Mock(rule=subreddit) self.mocked_read.side_effect = StreamParseException(message=words(1000)) diff --git a/src/newsreader/news/collection/tests/reddit/collector/tests.py b/src/newsreader/news/collection/tests/reddit/collector/tests.py index 1fd18b0..fa2f5d4 100644 --- a/src/newsreader/news/collection/tests/reddit/collector/tests.py +++ b/src/newsreader/news/collection/tests/reddit/collector/tests.py @@ -74,7 +74,7 @@ class RedditCollectorTestCase(TestCase): for subreddit in rules: with self.subTest(subreddit=subreddit): self.assertEquals(subreddit.succeeded, True) - self.assertEquals(subreddit.last_suceeded, timezone.now()) + self.assertEquals(subreddit.last_run, timezone.now()) self.assertEquals(subreddit.error, None) post = Post.objects.get( @@ -133,7 +133,7 @@ class RedditCollectorTestCase(TestCase): for subreddit in rules: with self.subTest(subreddit=subreddit): self.assertEquals(subreddit.succeeded, True) - self.assertEquals(subreddit.last_suceeded, timezone.now()) + self.assertEquals(subreddit.last_run, timezone.now()) self.assertEquals(subreddit.error, None) def test_not_found(self): diff --git a/src/newsreader/news/collection/tests/reddit/test_scheduler.py b/src/newsreader/news/collection/tests/reddit/test_scheduler.py index cd062b6..0f04d53 100644 --- a/src/newsreader/news/collection/tests/reddit/test_scheduler.py +++ b/src/newsreader/news/collection/tests/reddit/test_scheduler.py @@ -25,19 +25,19 @@ class RedditSchedulerTestCase(TestCase): CollectionRuleFactory( user=user_1, type=RuleTypeChoices.subreddit, - last_suceeded=timezone.now() - timedelta(days=4), + last_run=timezone.now() - timedelta(days=4), enabled=True, ), CollectionRuleFactory( user=user_1, type=RuleTypeChoices.subreddit, - last_suceeded=timezone.now() - timedelta(days=3), + last_run=timezone.now() - timedelta(days=3), enabled=True, ), CollectionRuleFactory( user=user_1, type=RuleTypeChoices.subreddit, - last_suceeded=timezone.now() - timedelta(days=2), + last_run=timezone.now() - timedelta(days=2), enabled=True, ), ] @@ -46,19 +46,19 @@ class RedditSchedulerTestCase(TestCase): CollectionRuleFactory( user=user_2, type=RuleTypeChoices.subreddit, - last_suceeded=timezone.now() - timedelta(days=4), + last_run=timezone.now() - timedelta(days=4), enabled=True, ), CollectionRuleFactory( user=user_2, type=RuleTypeChoices.subreddit, - last_suceeded=timezone.now() - timedelta(days=3), + last_run=timezone.now() - timedelta(days=3), enabled=True, ), CollectionRuleFactory( user=user_2, type=RuleTypeChoices.subreddit, - last_suceeded=timezone.now() - timedelta(days=2), + last_run=timezone.now() - timedelta(days=2), enabled=True, ), ] @@ -87,7 +87,7 @@ class RedditSchedulerTestCase(TestCase): CollectionRuleFactory.create_batch( name=f"rule-{index}", type=RuleTypeChoices.subreddit, - last_suceeded=timezone.now() - timedelta(seconds=index), + last_run=timezone.now() - timedelta(seconds=index), enabled=True, user=user, size=15, @@ -121,7 +121,7 @@ class RedditSchedulerTestCase(TestCase): CollectionRuleFactory( name=f"rule-{index}", type=RuleTypeChoices.subreddit, - last_suceeded=timezone.now() - timedelta(seconds=index), + last_run=timezone.now() - timedelta(seconds=index), enabled=True, user=user, ) diff --git a/src/newsreader/news/collection/tests/tests.py b/src/newsreader/news/collection/tests/tests.py index 363e0b5..c7f0bb0 100644 --- a/src/newsreader/news/collection/tests/tests.py +++ b/src/newsreader/news/collection/tests/tests.py @@ -1,10 +1,9 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import Mock, patch from django.test import TestCase from bs4 import BeautifulSoup -from newsreader.news.collection.base import URLBuilder, WebsiteStream from newsreader.news.collection.exceptions import ( StreamDeniedException, StreamException, @@ -13,6 +12,7 @@ from newsreader.news.collection.exceptions import ( StreamParseException, StreamTimeOutException, ) +from newsreader.news.collection.favicon import WebsiteStream, WebsiteURLBuilder from newsreader.news.collection.tests.factories import CollectionRuleFactory from .mocks import feed_mock_without_link, simple_feed_mock, simple_mock @@ -20,117 +20,125 @@ from .mocks import feed_mock_without_link, simple_feed_mock, simple_mock class WebsiteStreamTestCase(TestCase): def setUp(self): - self.patched_fetch = patch("newsreader.news.collection.base.fetch") + self.patched_fetch = patch("newsreader.news.collection.favicon.fetch") self.mocked_fetch = self.patched_fetch.start() def tearDown(self): patch.stopall() def test_simple(self): - self.mocked_fetch.return_value = MagicMock(content=simple_mock) + self.mocked_fetch.return_value = Mock(content=simple_mock) - rule = CollectionRuleFactory() - stream = WebsiteStream(rule.url) + rule = CollectionRuleFactory(website_url="https://www.bbc.co.uk/news/") + stream = WebsiteStream(rule) return_value = stream.read() - self.mocked_fetch.assert_called_once_with(rule.url) - self.assertEquals(return_value, (BeautifulSoup(simple_mock, "lxml"), stream)) + self.mocked_fetch.assert_called_once_with("https://www.bbc.co.uk/news/") + self.assertEquals( + return_value, (BeautifulSoup(simple_mock, features="lxml"), stream) + ) def test_raises_exception(self): self.mocked_fetch.side_effect = StreamException - rule = CollectionRuleFactory() - stream = WebsiteStream(rule.url) + rule = CollectionRuleFactory(website_url="https://www.bbc.co.uk/news/") + stream = WebsiteStream(rule) with self.assertRaises(StreamException): stream.read() - self.mocked_fetch.assert_called_once_with(rule.url) + self.mocked_fetch.assert_called_once_with("https://www.bbc.co.uk/news/") def test_raises_denied_exception(self): self.mocked_fetch.side_effect = StreamDeniedException - rule = CollectionRuleFactory() - stream = WebsiteStream(rule.url) + rule = CollectionRuleFactory(website_url="https://www.bbc.co.uk/news/") + stream = WebsiteStream(rule) with self.assertRaises(StreamDeniedException): stream.read() - self.mocked_fetch.assert_called_once_with(rule.url) + self.mocked_fetch.assert_called_once_with("https://www.bbc.co.uk/news/") def test_raises_stream_not_found_exception(self): self.mocked_fetch.side_effect = StreamNotFoundException - rule = CollectionRuleFactory() - stream = WebsiteStream(rule.url) + rule = CollectionRuleFactory(website_url="https://www.bbc.co.uk/news/") + stream = WebsiteStream(rule) with self.assertRaises(StreamNotFoundException): stream.read() - self.mocked_fetch.assert_called_once_with(rule.url) + self.mocked_fetch.assert_called_once_with("https://www.bbc.co.uk/news/") def test_stream_raises_time_out_exception(self): self.mocked_fetch.side_effect = StreamTimeOutException - rule = CollectionRuleFactory() - stream = WebsiteStream(rule.url) + rule = CollectionRuleFactory(website_url="https://www.bbc.co.uk/news/") + stream = WebsiteStream(rule) with self.assertRaises(StreamTimeOutException): stream.read() - self.mocked_fetch.assert_called_once_with(rule.url) + self.mocked_fetch.assert_called_once_with("https://www.bbc.co.uk/news/") def test_stream_raises_forbidden_exception(self): self.mocked_fetch.side_effect = StreamForbiddenException - rule = CollectionRuleFactory() - stream = WebsiteStream(rule.url) + rule = CollectionRuleFactory(website_url="https://www.bbc.co.uk/news/") + stream = WebsiteStream(rule) with self.assertRaises(StreamForbiddenException): stream.read() - self.mocked_fetch.assert_called_once_with(rule.url) + self.mocked_fetch.assert_called_once_with("https://www.bbc.co.uk/news/") - @patch("newsreader.news.collection.base.WebsiteStream.parse") + @patch("newsreader.news.collection.favicon.WebsiteStream.parse") def test_stream_raises_parse_exception(self, mocked_parse): - self.mocked_fetch.return_value = MagicMock() + self.mocked_fetch.return_value = Mock() mocked_parse.side_effect = StreamParseException - rule = CollectionRuleFactory() - stream = WebsiteStream(rule.url) + rule = CollectionRuleFactory(website_url="https://www.bbc.co.uk/news/") + stream = WebsiteStream(rule) with self.assertRaises(StreamParseException): stream.read() - self.mocked_fetch.assert_called_once_with(rule.url) + self.mocked_fetch.assert_called_once_with("https://www.bbc.co.uk/news/") -class URLBuilderTestCase(TestCase): +class WebsiteURLBuilderTestCase(TestCase): def test_simple(self): initial_rule = CollectionRuleFactory() - with URLBuilder((simple_feed_mock, MagicMock(rule=initial_rule))) as builder: - rule, url = builder.build() + with WebsiteURLBuilder(simple_feed_mock, Mock(rule=initial_rule)) as builder: + builder.build() + builder.save() - self.assertEquals(rule.pk, initial_rule.pk) - self.assertEquals(url, "https://www.bbc.co.uk/news/") + initial_rule.refresh_from_db() + + self.assertEquals(initial_rule.website_url, "https://www.bbc.co.uk/news/") def test_no_link(self): - initial_rule = CollectionRuleFactory() + initial_rule = CollectionRuleFactory(website_url=None) - with URLBuilder( - (feed_mock_without_link, MagicMock(rule=initial_rule)) + with WebsiteURLBuilder( + feed_mock_without_link, Mock(rule=initial_rule) ) as builder: - rule, url = builder.build() + builder.build() + builder.save() - self.assertEquals(rule.pk, initial_rule.pk) - self.assertEquals(url, None) + initial_rule.refresh_from_db() + + self.assertEquals(initial_rule.website_url, None) def test_no_data(self): - initial_rule = CollectionRuleFactory() + initial_rule = CollectionRuleFactory(website_url=None) - with URLBuilder((None, MagicMock(rule=initial_rule))) as builder: - rule, url = builder.build() + with WebsiteURLBuilder(None, Mock(rule=initial_rule)) as builder: + builder.build() + builder.save() - self.assertEquals(rule.pk, initial_rule.pk) - self.assertEquals(url, None) + initial_rule.refresh_from_db() + + self.assertEquals(initial_rule.website_url, None) diff --git a/src/newsreader/news/collection/tests/twitter/__init__.py b/src/newsreader/news/collection/tests/twitter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/twitter/builder/__init__.py b/src/newsreader/news/collection/tests/twitter/builder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/twitter/builder/mocks.py b/src/newsreader/news/collection/tests/twitter/builder/mocks.py new file mode 100644 index 0000000..b330f2f --- /dev/null +++ b/src/newsreader/news/collection/tests/twitter/builder/mocks.py @@ -0,0 +1,2187 @@ +# retrieved with: +# curl -X GET -H "Authorization: Bearer " "https://api.twitter.com/1.1/statuses/user_timeline.json?screen_name=twitterapi&tweet_mode=extended" | python3 -m json.tool --sort-keys +# +# see https://developer.twitter.com/en/docs/twitter-api/v1/data-dictionary/overview/tweet-object +# and https://developer.twitter.com/en/docs/twitter-api/v1/data-dictionary/overview/extended-entities-object +# for more information about tweet objects + +simple_mock = [ + { + "contributors": None, + "coordinates": None, + "created_at": "Fri Aug 07 00:17:05 +0000 2020", + "display_text_range": [11, 59], + "entities": { + "hashtags": [], + "symbols": [], + "urls": [ + { + "display_url": "youtu.be/rDy7tPf6CT8", + "expanded_url": "https://youtu.be/rDy7tPf6CT8", + "indices": [36, 59], + "url": "https://t.co/trAcIxBMlX", + } + ], + "user_mentions": [ + { + "id": 975844884606275587, + "id_str": "975844884606275587", + "indices": [0, 10], + "name": "ArieNeo", + "screen_name": "ArieNeoSC", + } + ], + }, + "favorite_count": 19, + "favorited": False, + "full_text": "@ArieNeoSC Here you go, goodnight!\n\nhttps://t.co/trAcIxBMlX", + "geo": None, + "id": 1291528756373286914, + "id_str": "1291528756373286914", + "in_reply_to_screen_name": "ArieNeoSC", + "in_reply_to_status_id": 1291507356313038850, + "in_reply_to_status_id_str": "1291507356313038850", + "in_reply_to_user_id": 975844884606275587, + "in_reply_to_user_id_str": "975844884606275587", + "is_quote_status": False, + "lang": "en", + "place": None, + "possibly_sensitive": False, + "retweet_count": 5, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4588, + "follow_request_sent": None, + "followers_count": 106169, + "following": None, + "friends_count": 201, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 890, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6210, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, + { + "contributors": None, + "coordinates": None, + "created_at": "Wed Jul 29 19:01:47 +0000 2020", + "display_text_range": [10, 98], + "entities": { + "hashtags": [], + "symbols": [], + "urls": [], + "user_mentions": [ + { + "id": 435221600, + "id_str": "435221600", + "indices": [0, 9], + "name": "Christopher Blough", + "screen_name": "RelicCcb", + } + ], + }, + "favorite_count": 1, + "favorited": False, + "full_text": "@RelicCcb Hi Christoper, we have checked the status of your investigation and it is still ongoing.", + "geo": None, + "id": 1288550304095416320, + "id_str": "1288550304095416320", + "in_reply_to_screen_name": "RelicCcb", + "in_reply_to_status_id": 1288475147951898625, + "in_reply_to_status_id_str": "1288475147951898625", + "in_reply_to_user_id": 435221600, + "in_reply_to_user_id_str": "435221600", + "is_quote_status": False, + "lang": "en", + "place": None, + "retweet_count": 0, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4588, + "follow_request_sent": None, + "followers_count": 106169, + "following": None, + "friends_count": 201, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 890, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6210, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, +] + +# contains tweets with "extended_entities" keys which contains native media objects +# which are in this case of the type "photo" +image_mock = [ + { + "contributors": None, + "coordinates": None, + "created_at": "Fri Jun 05 22:51:46 +0000 2020", + "entities": { + "hashtags": [], + "media": [ + { + "display_url": "pic.twitter.com/VjEeDrL1iA", + "expanded_url": "https://twitter.com/knxwledge/status/1269039237166321664/photo/1", + "id": 1269039233072689152, + "id_str": "1269039233072689152", + "indices": [2, 25], + "media_url": "http://pbs.twimg.com/media/EZyIdXVU8AACPCz.jpg", + "media_url_https": "https://pbs.twimg.com/media/EZyIdXVU8AACPCz.jpg", + "sizes": { + "large": {"h": 1073, "resize": "fit", "w": 1125}, + "medium": {"h": 1073, "resize": "fit", "w": 1125}, + "small": {"h": 649, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/VjEeDrL1iA", + } + ], + "symbols": [], + "urls": [], + "user_mentions": [], + }, + "extended_entities": { + "media": [ + { + "display_url": "pic.twitter.com/VjEeDrL1iA", + "expanded_url": "https://twitter.com/knxwledge/status/1269039237166321664/photo/1", + "id": 1269039233072689152, + "id_str": "1269039233072689152", + "indices": [2, 25], + "media_url": "http://pbs.twimg.com/media/EZyIdXVU8AACPCz.jpg", + "media_url_https": "https://pbs.twimg.com/media/EZyIdXVU8AACPCz.jpg", + "sizes": { + "large": {"h": 1073, "resize": "fit", "w": 1125}, + "medium": {"h": 1073, "resize": "fit", "w": 1125}, + "small": {"h": 649, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/VjEeDrL1iA", + }, + { + "display_url": "pic.twitter.com/VjEeDrL1iA", + "expanded_url": "https://twitter.com/knxwledge/status/1269039237166321664/photo/1", + "id": 1269039233068527618, + "id_str": "1269039233068527618", + "indices": [2, 25], + "media_url": "http://pbs.twimg.com/media/EZyIdXUVcAI3Cju.jpg", + "media_url_https": "https://pbs.twimg.com/media/EZyIdXUVcAI3Cju.jpg", + "sizes": { + "large": {"h": 992, "resize": "fit", "w": 1472}, + "medium": {"h": 809, "resize": "fit", "w": 1200}, + "small": {"h": 458, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/VjEeDrL1iA", + }, + ] + }, + "favorite_count": 2139, + "favorited": False, + "geo": None, + "id": 1269039237166321664, + "id_str": "1269039237166321664", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "und", + "place": None, + "possibly_sensitive": False, + "possibly_sensitive_appealable": False, + "retweet_count": 427, + "retweeted": False, + "source": 'Twitter for iPhone', + "full_text": "_ https://t.co/VjEeDrL1iA", + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Tue Nov 14 19:00:00 +0000 2017", + "default_profile": False, + "default_profile_image": False, + "description": "Grammy\u00ae Award Winning Beatmakr. https://t.co/SN23ei3EeC https://t.co/EkGRhZ1Bw9 https://t.co/eEb4NOmJLo", + "entities": { + "description": { + "urls": [ + { + "display_url": "soundcloud.com/knxwledge", + "expanded_url": "http://soundcloud.com/knxwledge", + "indices": [32, 55], + "url": "https://t.co/SN23ei3EeC", + }, + { + "display_url": "knxwledge.bandcamp.com", + "expanded_url": "http://knxwledge.bandcamp.com", + "indices": [56, 79], + "url": "https://t.co/EkGRhZ1Bw9", + }, + { + "display_url": "twitch.tv/knxwledge", + "expanded_url": "http://twitch.tv/knxwledge", + "indices": [80, 103], + "url": "https://t.co/eEb4NOmJLo", + }, + ] + }, + "url": { + "urls": [ + { + "display_url": "instagram.com/knxwledge/?hl=\u2026", + "expanded_url": "https://www.instagram.com/knxwledge/?hl=en", + "indices": [0, 23], + "url": "https://t.co/UcMYfiQXLx", + } + ] + }, + }, + "favourites_count": 363, + "follow_request_sent": None, + "followers_count": 31194, + "following": None, + "friends_count": 15, + "geo_enabled": False, + "has_extended_profile": False, + "id": 930510644763287552, + "id_str": "930510644763287552", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 56, + "location": "", + "name": "knxwledge", + "notifications": None, + "profile_background_color": "000000", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme1/bg.png", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme1/bg.png", + "profile_background_tile": False, + "profile_image_url": "http://pbs.twimg.com/profile_images/1274913160898592768/jFi4VDtJ_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/1274913160898592768/jFi4VDtJ_normal.jpg", + "profile_link_color": "ABB8C2", + "profile_sidebar_border_color": "000000", + "profile_sidebar_fill_color": "000000", + "profile_text_color": "000000", + "profile_use_background_image": False, + "protected": False, + "screen_name": "knxwledge", + "statuses_count": 713, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/UcMYfiQXLx", + "utc_offset": None, + "verified": False, + }, + } +] + +# contains tweets with "extended_entities" keys which contains native media objects +# which are in this case of the type "video" +video_mock = [ + { + "contributors": None, + "coordinates": None, + "created_at": "Wed Aug 05 18:36:00 +0000 2020", + "display_text_range": [0, 196], + "entities": { + "hashtags": [], + "media": [ + { + "display_url": "pic.twitter.com/mZ8CAuq3SH", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1291080532361527296/video/1", + "id": 1291074294747770880, + "id_str": "1291074294747770880", + "indices": [197, 220], + "media_url": "http://pbs.twimg.com/media/EerWyexUEAQhRL1.jpg", + "media_url_https": "https://pbs.twimg.com/media/EerWyexUEAQhRL1.jpg", + "sizes": { + "large": {"h": 720, "resize": "fit", "w": 1280}, + "medium": {"h": 675, "resize": "fit", "w": 1200}, + "small": {"h": 383, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/mZ8CAuq3SH", + } + ], + "symbols": [], + "urls": [ + { + "display_url": "robertsspaceindustries.com/greycatroc", + "expanded_url": "http://robertsspaceindustries.com/greycatroc", + "indices": [173, 196], + "url": "https://t.co/2aH7qdOfSk", + } + ], + "user_mentions": [], + }, + "extended_entities": { + "media": [ + { + "additional_media_info": { + "description": "", + "embeddable": True, + "monetizable": False, + "title": "", + }, + "display_url": "pic.twitter.com/mZ8CAuq3SH", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1291080532361527296/video/1", + "id": 1291074294747770880, + "id_str": "1291074294747770880", + "indices": [197, 220], + "media_url": "http://pbs.twimg.com/media/EerWyexUEAQhRL1.jpg", + "media_url_https": "https://pbs.twimg.com/media/EerWyexUEAQhRL1.jpg", + "sizes": { + "large": {"h": 720, "resize": "fit", "w": 1280}, + "medium": {"h": 675, "resize": "fit", "w": 1200}, + "small": {"h": 383, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "video", + "url": "https://t.co/mZ8CAuq3SH", + "video_info": { + "aspect_ratio": [16, 9], + "duration_millis": 82967, + "variants": [ + { + "content_type": "application/x-mpegURL", + "url": "https://video.twimg.com/amplify_video/1291074294747770880/pl/kMYgFEoRyoW99o-i.m3u8?tag=13", + }, + { + "bitrate": 2176000, + "content_type": "video/mp4", + "url": "https://video.twimg.com/amplify_video/1291074294747770880/vid/1280x720/J05_p6q74ZUN4csg.mp4?tag=13", + }, + { + "bitrate": 832000, + "content_type": "video/mp4", + "url": "https://video.twimg.com/amplify_video/1291074294747770880/vid/640x360/ya3fVKeRdBs3cOoF.mp4?tag=13", + }, + { + "bitrate": 288000, + "content_type": "video/mp4", + "url": "https://video.twimg.com/amplify_video/1291074294747770880/vid/480x270/WQkAozOts-hRoU1I.mp4?tag=13", + }, + ], + }, + } + ] + }, + "favorite_count": 289, + "favorited": False, + "full_text": "Small enough to access hard-to-reach ore deposits, but with enough power to get through the tough jobs, Greycat\u2019s ROC perfectly complements any mining operation. \n\nDetails: https://t.co/2aH7qdOfSk https://t.co/mZ8CAuq3SH", + "geo": None, + "id": 1291080532361527296, + "id_str": "1291080532361527296", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "possibly_sensitive": False, + "retweet_count": 64, + "retweeted": False, + "source": 'Twitter Media Studio', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4588, + "follow_request_sent": None, + "followers_count": 106169, + "following": None, + "friends_count": 201, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 890, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6210, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, + { + "contributors": None, + "coordinates": None, + "created_at": "Wed Aug 05 18:31:27 +0000 2020", + "display_text_range": [0, 213], + "entities": { + "hashtags": [{"indices": [157, 169], "text": "StarCitizen"}], + "media": [ + { + "display_url": "pic.twitter.com/lri5QijMoA", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1291079386821582849/video/1", + "id": 1291070740347813889, + "id_str": "1291070740347813889", + "indices": [214, 237], + "media_url": "http://pbs.twimg.com/media/EerUMgyUwAAgj9w.jpg", + "media_url_https": "https://pbs.twimg.com/media/EerUMgyUwAAgj9w.jpg", + "sizes": { + "large": {"h": 720, "resize": "fit", "w": 1280}, + "medium": {"h": 675, "resize": "fit", "w": 1200}, + "small": {"h": 383, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/lri5QijMoA", + } + ], + "symbols": [], + "urls": [ + { + "display_url": "robertsspaceindustries.com/comm-link/tran\u2026", + "expanded_url": "https://robertsspaceindustries.com/comm-link/transmission/17648-Alpha-310-Flight-Fight", + "indices": [190, 213], + "url": "https://t.co/6jT1yuZMiR", + } + ], + "user_mentions": [], + }, + "extended_entities": { + "media": [ + { + "additional_media_info": { + "description": "", + "embeddable": True, + "monetizable": False, + "title": "", + }, + "display_url": "pic.twitter.com/lri5QijMoA", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1291079386821582849/video/1", + "id": 1291070740347813889, + "id_str": "1291070740347813889", + "indices": [214, 237], + "media_url": "http://pbs.twimg.com/media/EerUMgyUwAAgj9w.jpg", + "media_url_https": "https://pbs.twimg.com/media/EerUMgyUwAAgj9w.jpg", + "sizes": { + "large": {"h": 720, "resize": "fit", "w": 1280}, + "medium": {"h": 675, "resize": "fit", "w": 1200}, + "small": {"h": 383, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "video", + "url": "https://t.co/lri5QijMoA", + "video_info": { + "aspect_ratio": [16, 9], + "duration_millis": 83633, + "variants": [ + { + "bitrate": 288000, + "content_type": "video/mp4", + "url": "https://video.twimg.com/amplify_video/1291070740347813889/vid/480x270/oGdSeLr5QQ-XcTns.mp4?tag=13", + }, + { + "bitrate": 2176000, + "content_type": "video/mp4", + "url": "https://video.twimg.com/amplify_video/1291070740347813889/vid/1280x720/bql0evKsgYZhGPNP.mp4?tag=13", + }, + { + "bitrate": 832000, + "content_type": "video/mp4", + "url": "https://video.twimg.com/amplify_video/1291070740347813889/vid/640x360/lSL6mqB53HnwrUo4.mp4?tag=13", + }, + { + "content_type": "application/x-mpegURL", + "url": "https://video.twimg.com/amplify_video/1291070740347813889/pl/_jJ-AYWSMr8ZS1WP.m3u8?tag=13", + }, + ], + }, + } + ] + }, + "favorite_count": 429, + "favorited": False, + "full_text": "Harness the power of improved high-speed dynamic combat. Feel the thrill of atmospheric flight like never before. Alpha 3.10 will change the way you play. \ud83d\ude80 #StarCitizen\n\nGet in the 'verse: https://t.co/6jT1yuZMiR https://t.co/lri5QijMoA", + "geo": None, + "id": 1291079386821582849, + "id_str": "1291079386821582849", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "possibly_sensitive": False, + "retweet_count": 117, + "retweeted": False, + "source": 'Twitter Media Studio', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4588, + "follow_request_sent": None, + "followers_count": 106169, + "following": None, + "friends_count": 201, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 890, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6210, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, +] + +video_without_bitrate_mock = [ + { + "contributors": None, + "coordinates": None, + "created_at": "Wed Aug 05 18:36:00 +0000 2020", + "display_text_range": [0, 196], + "entities": { + "hashtags": [], + "media": [ + { + "display_url": "pic.twitter.com/mZ8CAuq3SH", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1291080532361527296/video/1", + "id": 1291074294747770880, + "id_str": "1291074294747770880", + "indices": [197, 220], + "media_url": "http://pbs.twimg.com/media/EerWyexUEAQhRL1.jpg", + "media_url_https": "https://pbs.twimg.com/media/EerWyexUEAQhRL1.jpg", + "sizes": { + "large": {"h": 720, "resize": "fit", "w": 1280}, + "medium": {"h": 675, "resize": "fit", "w": 1200}, + "small": {"h": 383, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/mZ8CAuq3SH", + } + ], + "symbols": [], + "urls": [ + { + "display_url": "robertsspaceindustries.com/greycatroc", + "expanded_url": "http://robertsspaceindustries.com/greycatroc", + "indices": [173, 196], + "url": "https://t.co/2aH7qdOfSk", + } + ], + "user_mentions": [], + }, + "extended_entities": { + "media": [ + { + "additional_media_info": { + "description": "", + "embeddable": True, + "monetizable": False, + "title": "", + }, + "display_url": "pic.twitter.com/mZ8CAuq3SH", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1291080532361527296/video/1", + "id": 1291074294747770880, + "id_str": "1291074294747770880", + "indices": [197, 220], + "media_url": "http://pbs.twimg.com/media/EerWyexUEAQhRL1.jpg", + "media_url_https": "https://pbs.twimg.com/media/EerWyexUEAQhRL1.jpg", + "sizes": { + "large": {"h": 720, "resize": "fit", "w": 1280}, + "medium": {"h": 675, "resize": "fit", "w": 1200}, + "small": {"h": 383, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "video", + "url": "https://t.co/mZ8CAuq3SH", + "video_info": { + "aspect_ratio": [16, 9], + "duration_millis": 82967, + "variants": [ + { + "content_type": "application/x-mpegURL", + "url": "https://video.twimg.com/amplify_video/1291074294747770880/pl/kMYgFEoRyoW99o-i.m3u8?tag=13", + } + ], + }, + } + ] + }, + "favorite_count": 289, + "favorited": False, + "full_text": "Small enough to access hard-to-reach ore deposits, but with enough power to get through the tough jobs, Greycat\u2019s ROC perfectly complements any mining operation. \n\nDetails: https://t.co/2aH7qdOfSk https://t.co/mZ8CAuq3SH", + "geo": None, + "id": 1291080532361527296, + "id_str": "1291080532361527296", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "possibly_sensitive": False, + "retweet_count": 64, + "retweeted": False, + "source": 'Twitter Media Studio', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4588, + "follow_request_sent": None, + "followers_count": 106169, + "following": None, + "friends_count": 201, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 890, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6210, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + } +] + +# contains tweets with a "retweeted_status" key containing the retweeted tweet. +# the "retweet" cannot add hashtags, URLs or other details, see https://developer.twitter.com/en/docs/twitter-api/v1/data-dictionary/overview/entities-object#retweets-quote +retweet_mock = [ + { + "contributors": None, + "coordinates": None, + "created_at": "Wed Aug 05 21:01:02 +0000 2020", + "display_text_range": [0, 140], + "entities": { + "hashtags": [{"indices": [27, 39], "text": "StarCitizen"}], + "symbols": [], + "urls": [], + "user_mentions": [ + { + "id": 859293278100914176, + "id_str": "859293278100914176", + "indices": [3, 14], + "name": "Aleksandr Belov", + "screen_name": "Narayan_N7", + } + ], + }, + "favorite_count": 0, + "favorited": False, + "full_text": "RT @Narayan_N7: New video! #StarCitizen 3.9 vs. 3.10 comparison!\nSo, the patch 3.10 came out, which brought us quite a lot of changes!\ud83d\ude42\nPle\u2026", + "geo": None, + "id": 1291117030486106112, + "id_str": "1291117030486106112", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "retweet_count": 26, + "retweeted": False, + "retweeted_status": { + "contributors": None, + "coordinates": None, + "created_at": "Wed Aug 05 18:15:34 +0000 2020", + "display_text_range": [0, 250], + "entities": { + "hashtags": [{"indices": [11, 23], "text": "StarCitizen"}], + "symbols": [], + "urls": [ + { + "display_url": "youtu.be/aXXGnCbEas0", + "expanded_url": "https://youtu.be/aXXGnCbEas0", + "indices": [227, 250], + "url": "https://t.co/j4QahHzbw4", + } + ], + "user_mentions": [ + { + "id": 803542770, + "id_str": "803542770", + "indices": [193, 209], + "name": "Star Citizen", + "screen_name": "RobertsSpaceInd", + }, + { + "id": 803697073, + "id_str": "803697073", + "indices": [211, 225], + "name": "Cloud Imperium Games", + "screen_name": "CloudImperium", + }, + ], + }, + "favorite_count": 97, + "favorited": False, + "full_text": "New video! #StarCitizen 3.9 vs. 3.10 comparison!\nSo, the patch 3.10 came out, which brought us quite a lot of changes!\ud83d\ude42\nPlease, share it with your friends!\ud83d\ude4f\n\nEnjoy watching and stay safe! \u2764\ufe0f\u263a\ufe0f\n@RobertsSpaceInd\n\n@CloudImperium\n\nhttps://t.co/j4QahHzbw4", + "geo": None, + "id": 1291075388798533633, + "id_str": "1291075388798533633", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "possibly_sensitive": False, + "retweet_count": 26, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Tue May 02 06:27:37 +0000 2017", + "default_profile": True, + "default_profile_image": False, + "description": "Enlist to Star Citizen: https://t.co/JOei50wjGK Content creator. #IWantToWorkAtCIG \n#StarCitizen #video #youtube #flickr #4K #panorama", + "entities": { + "description": { + "urls": [ + { + "display_url": "goo.gl/8CbEZm", + "expanded_url": "http://goo.gl/8CbEZm", + "indices": [24, 47], + "url": "https://t.co/JOei50wjGK", + } + ] + }, + "url": { + "urls": [ + { + "display_url": "youtube.com/user/sashaMOHC\u2026", + "expanded_url": "https://www.youtube.com/user/sashaMOHCTPwhite", + "indices": [0, 23], + "url": "https://t.co/ise14uN9Ja", + } + ] + }, + }, + "favourites_count": 1882, + "follow_request_sent": None, + "followers_count": 489, + "following": None, + "friends_count": 80, + "geo_enabled": True, + "has_extended_profile": True, + "id": 859293278100914176, + "id_str": "859293278100914176", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 16, + "location": "\u0421\u0430\u043d\u043a\u0442-\u041f\u0435\u0442\u0435\u0440\u0431\u0443\u0440\u0433, \u0420\u043e\u0441\u0441\u0438\u044f", + "name": "Aleksandr Belov", + "notifications": None, + "profile_background_color": "F5F8FA", + "profile_background_image_url": None, + "profile_background_image_url_https": None, + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/859293278100914176/1576841460", + "profile_image_url": "http://pbs.twimg.com/profile_images/1203066581573607425/5TEkxVJ3_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/1203066581573607425/5TEkxVJ3_normal.jpg", + "profile_link_color": "1DA1F2", + "profile_sidebar_border_color": "C0DEED", + "profile_sidebar_fill_color": "DDEEF6", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "Narayan_N7", + "statuses_count": 1283, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/ise14uN9Ja", + "utc_offset": None, + "verified": False, + }, + }, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4588, + "follow_request_sent": None, + "followers_count": 106169, + "following": None, + "friends_count": 201, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 890, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6210, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, + { + "contributors": None, + "coordinates": None, + "created_at": "Thu Jul 30 13:15:25 +0000 2020", + "display_text_range": [0, 140], + "entities": { + "hashtags": [{"indices": [24, 40], "text": "CountdownToMars"}], + "symbols": [], + "urls": [], + "user_mentions": [ + { + "id": 11348282, + "id_str": "11348282", + "indices": [3, 8], + "name": "NASA", + "screen_name": "NASA", + }, + { + "id": 1232783237623119872, + "id_str": "1232783237623119872", + "indices": [123, 137], + "name": "NASA's Perseverance Mars Rover", + "screen_name": "NASAPersevere", + }, + ], + }, + "favorite_count": 0, + "favorited": False, + "full_text": "RT @NASA: LIVE NOW: The #CountdownToMars begins. \n\nWe are launching a historic mission to the Red Planet. Tune in to watch @NASAPersevere l\u2026", + "geo": None, + "id": 1288825524878336000, + "id_str": "1288825524878336000", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "retweet_count": 8867, + "retweeted": False, + "retweeted_status": { + "contributors": None, + "coordinates": None, + "created_at": "Thu Jul 30 11:01:06 +0000 2020", + "display_text_range": [0, 236], + "entities": { + "hashtags": [{"indices": [14, 30], "text": "CountdownToMars"}], + "symbols": [], + "urls": [ + { + "display_url": "twitter.com/i/broadcasts/1\u2026", + "expanded_url": "https://twitter.com/i/broadcasts/1RDGlrkoEzNxL", + "indices": [213, 236], + "url": "https://t.co/JxyRCol01i", + } + ], + "user_mentions": [ + { + "id": 1232783237623119872, + "id_str": "1232783237623119872", + "indices": [113, 127], + "name": "NASA's Perseverance Mars Rover", + "screen_name": "NASAPersevere", + } + ], + }, + "favorite_count": 18327, + "favorited": False, + "full_text": "LIVE NOW: The #CountdownToMars begins. \n\nWe are launching a historic mission to the Red Planet. Tune in to watch @NASAPersevere liftoff and begin her mission to search for signs of ancient life on another world: https://t.co/JxyRCol01i", + "geo": None, + "id": 1288791726165983233, + "id_str": "1288791726165983233", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "possibly_sensitive": False, + "retweet_count": 8867, + "retweeted": False, + "source": 'Twitter Media Studio', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Dec 19 20:20:32 +0000 2007", + "default_profile": False, + "default_profile_image": False, + "description": "Explore the universe and our home planet with NASA \ud83c\udf0e We usually post in EDT.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "nasa.gov", + "expanded_url": "http://www.nasa.gov/", + "indices": [0, 23], + "url": "https://t.co/HMJJbimQpV", + } + ] + }, + }, + "favourites_count": 11658, + "follow_request_sent": None, + "followers_count": 39440029, + "following": None, + "friends_count": 222, + "geo_enabled": False, + "has_extended_profile": True, + "id": 11348282, + "id_str": "11348282", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 92535, + "location": "", + "name": "NASA", + "notifications": None, + "profile_background_color": "000000", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme1/bg.png", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme1/bg.png", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/11348282/1596217000", + "profile_image_url": "http://pbs.twimg.com/profile_images/1091070803184177153/TI2qItoi_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/1091070803184177153/TI2qItoi_normal.jpg", + "profile_link_color": "205BA7", + "profile_sidebar_border_color": "000000", + "profile_sidebar_fill_color": "F3F2F2", + "profile_text_color": "000000", + "profile_use_background_image": True, + "protected": False, + "screen_name": "NASA", + "statuses_count": 61920, + "time_zone": None, + "translator_type": "regular", + "url": "https://t.co/HMJJbimQpV", + "utc_offset": None, + "verified": True, + }, + }, + "source": 'Twitter for iPhone', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4588, + "follow_request_sent": None, + "followers_count": 106169, + "following": None, + "friends_count": 201, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 890, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6210, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, +] + +# contains tweets with a "quoted_status" key containing the quoted tweet. +# quoted tweets can add hashtags, URL's and other details as it adds content "on top" of the quoted tweet see https://developer.twitter.com/en/docs/twitter-api/v1/data-dictionary/overview/entities-object#retweets-quotes +quoted_mock = [ + { + "contributors": None, + "coordinates": None, + "created_at": "Wed Aug 05 00:05:24 +0000 2020", + "display_text_range": [0, 13], + "entities": { + "hashtags": [], + "symbols": [], + "urls": [ + { + "display_url": "twitter.com/hugolisoir/sta\u2026", + "expanded_url": "https://twitter.com/hugolisoir/status/1290778178793897992", + "indices": [14, 37], + "url": "https://t.co/WyznJwCJLp", + } + ], + "user_mentions": [], + }, + "favorite_count": 576, + "favorited": False, + "full_text": "Bonne nuit \ud83c\udf3a\ud83d\udeeb https://t.co/WyznJwCJLp", + "geo": None, + "id": 1290801039075979264, + "id_str": "1290801039075979264", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": True, + "lang": "fr", + "place": None, + "possibly_sensitive": False, + "quoted_status": { + "contributors": None, + "coordinates": None, + "created_at": "Tue Aug 04 22:34:33 +0000 2020", + "display_text_range": [0, 57], + "entities": { + "hashtags": [{"indices": [0, 12], "text": "Starcitizen"}], + "media": [ + { + "display_url": "pic.twitter.com/xCXun68V3r", + "expanded_url": "https://twitter.com/hugolisoir/status/1290778178793897992/video/1", + "id": 1290778053623382017, + "id_str": "1290778053623382017", + "indices": [58, 81], + "media_url": "http://pbs.twimg.com/ext_tw_video_thumb/1290778053623382017/pu/img/FFHKsCa_gYLNrriu.jpg", + "media_url_https": "https://pbs.twimg.com/ext_tw_video_thumb/1290778053623382017/pu/img/FFHKsCa_gYLNrriu.jpg", + "sizes": { + "large": {"h": 720, "resize": "fit", "w": 1280}, + "medium": {"h": 675, "resize": "fit", "w": 1200}, + "small": {"h": 383, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/xCXun68V3r", + } + ], + "symbols": [], + "urls": [], + "user_mentions": [ + { + "id": 803542770, + "id_str": "803542770", + "indices": [41, 57], + "name": "Star Citizen", + "screen_name": "RobertsSpaceInd", + } + ], + }, + "extended_entities": { + "media": [ + { + "additional_media_info": {"monetizable": False}, + "display_url": "pic.twitter.com/xCXun68V3r", + "expanded_url": "https://twitter.com/hugolisoir/status/1290778178793897992/video/1", + "id": 1290778053623382017, + "id_str": "1290778053623382017", + "indices": [58, 81], + "media_url": "http://pbs.twimg.com/ext_tw_video_thumb/1290778053623382017/pu/img/FFHKsCa_gYLNrriu.jpg", + "media_url_https": "https://pbs.twimg.com/ext_tw_video_thumb/1290778053623382017/pu/img/FFHKsCa_gYLNrriu.jpg", + "sizes": { + "large": {"h": 720, "resize": "fit", "w": 1280}, + "medium": {"h": 675, "resize": "fit", "w": 1200}, + "small": {"h": 383, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "video", + "url": "https://t.co/xCXun68V3r", + "video_info": { + "aspect_ratio": [16, 9], + "duration_millis": 39901, + "variants": [ + { + "bitrate": 832000, + "content_type": "video/mp4", + "url": "https://video.twimg.com/ext_tw_video/1290778053623382017/pu/vid/640x360/jYjO0H2SYSycTi-e.mp4?tag=10", + }, + { + "content_type": "application/x-mpegURL", + "url": "https://video.twimg.com/ext_tw_video/1290778053623382017/pu/pl/wFnVMLjVWi7OKy2o.m3u8?tag=10", + }, + { + "bitrate": 2176000, + "content_type": "video/mp4", + "url": "https://video.twimg.com/ext_tw_video/1290778053623382017/pu/vid/1280x720/H-BXvYdM0AcSKXpk.mp4?tag=10", + }, + { + "bitrate": 256000, + "content_type": "video/mp4", + "url": "https://video.twimg.com/ext_tw_video/1290778053623382017/pu/vid/480x270/aWhSjP1gK7djKZUK.mp4?tag=10", + }, + ], + }, + } + ] + }, + "favorite_count": 400, + "favorited": False, + "full_text": "#Starcitizen Le jeu est beau. Bonne nuit @RobertsSpaceInd https://t.co/xCXun68V3r", + "geo": None, + "id": 1290778178793897992, + "id_str": "1290778178793897992", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "fr", + "place": None, + "possibly_sensitive": False, + "retweet_count": 76, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Tue Mar 22 12:00:36 +0000 2011", + "default_profile": False, + "default_profile_image": False, + "description": "Youtuber Partner / Twitch Partner / Membre du @CurryClub_CC\nInsta - hugolisoir\nParrain de @AbyssalProject", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "youtube.com/channel/UCDC6D\u2026", + "expanded_url": "https://www.youtube.com/channel/UCDC6DBi0kRp6Jk21xqfvFLA", + "indices": [0, 23], + "url": "https://t.co/p3CVR2I068", + } + ] + }, + }, + "favourites_count": 20935, + "follow_request_sent": None, + "followers_count": 23269, + "following": None, + "friends_count": 703, + "geo_enabled": True, + "has_extended_profile": False, + "id": 270320632, + "id_str": "270320632", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 116, + "location": "Nantes, France", + "name": "Hugo Lisoir #ZLAN2020", + "notifications": None, + "profile_background_color": "000000", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme15/bg.png", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme15/bg.png", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/270320632/1499086260", + "profile_image_url": "http://pbs.twimg.com/profile_images/1264841251305730048/vyUJVCvW_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/1264841251305730048/vyUJVCvW_normal.jpg", + "profile_link_color": "ABB8C2", + "profile_sidebar_border_color": "000000", + "profile_sidebar_fill_color": "000000", + "profile_text_color": "000000", + "profile_use_background_image": False, + "protected": False, + "screen_name": "hugolisoir", + "statuses_count": 7507, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/p3CVR2I068", + "utc_offset": None, + "verified": False, + }, + }, + "quoted_status_id": 1290778178793897992, + "quoted_status_id_str": "1290778178793897992", + "quoted_status_permalink": { + "display": "twitter.com/hugolisoir/sta\u2026", + "expanded": "https://twitter.com/hugolisoir/status/1290778178793897992", + "url": "https://t.co/WyznJwCJLp", + }, + "retweet_count": 60, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4588, + "follow_request_sent": None, + "followers_count": 106169, + "following": None, + "friends_count": 201, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 890, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6210, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, + { + "contributors": None, + "coordinates": None, + "created_at": "Fri Jul 31 22:00:55 +0000 2020", + "display_text_range": [0, 32], + "entities": { + "hashtags": [], + "symbols": [], + "urls": [ + { + "display_url": "twitter.com/UberFacts/stat\u2026", + "expanded_url": "https://twitter.com/UberFacts/status/1289273883493675009", + "indices": [33, 56], + "url": "https://t.co/LLPVr8oU7F", + } + ], + "user_mentions": [], + }, + "favorite_count": 263, + "favorited": False, + "full_text": "Here's to our lovely Avocados! \ud83d\udd79 https://t.co/LLPVr8oU7F", + "geo": None, + "id": 1289320160021495809, + "id_str": "1289320160021495809", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": True, + "lang": "en", + "place": None, + "possibly_sensitive": False, + "quoted_status": { + "contributors": None, + "coordinates": None, + "created_at": "Fri Jul 31 18:57:02 +0000 2020", + "display_text_range": [0, 34], + "entities": { + "hashtags": [], + "media": [ + { + "display_url": "pic.twitter.com/8QRycx9QB2", + "expanded_url": "https://twitter.com/UberFacts/status/1289273883493675009/photo/1", + "id": 1289273880570363907, + "id_str": "1289273880570363907", + "indices": [35, 58], + "media_url": "http://pbs.twimg.com/tweet_video_thumb/EeRrw3WWAAMKVF0.jpg", + "media_url_https": "https://pbs.twimg.com/tweet_video_thumb/EeRrw3WWAAMKVF0.jpg", + "sizes": { + "large": {"h": 500, "resize": "fit", "w": 500}, + "medium": {"h": 500, "resize": "fit", "w": 500}, + "small": {"h": 500, "resize": "fit", "w": 500}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/8QRycx9QB2", + } + ], + "symbols": [], + "urls": [], + "user_mentions": [], + }, + "extended_entities": { + "media": [ + { + "display_url": "pic.twitter.com/8QRycx9QB2", + "expanded_url": "https://twitter.com/UberFacts/status/1289273883493675009/photo/1", + "id": 1289273880570363907, + "id_str": "1289273880570363907", + "indices": [35, 58], + "media_url": "http://pbs.twimg.com/tweet_video_thumb/EeRrw3WWAAMKVF0.jpg", + "media_url_https": "https://pbs.twimg.com/tweet_video_thumb/EeRrw3WWAAMKVF0.jpg", + "sizes": { + "large": {"h": 500, "resize": "fit", "w": 500}, + "medium": {"h": 500, "resize": "fit", "w": 500}, + "small": {"h": 500, "resize": "fit", "w": 500}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "animated_gif", + "url": "https://t.co/8QRycx9QB2", + "video_info": { + "aspect_ratio": [1, 1], + "variants": [ + { + "bitrate": 0, + "content_type": "video/mp4", + "url": "https://video.twimg.com/tweet_video/EeRrw3WWAAMKVF0.mp4", + } + ], + }, + } + ] + }, + "favorite_count": 1550, + "favorited": False, + "full_text": "July 31st is National Avocado Day! https://t.co/8QRycx9QB2", + "geo": None, + "id": 1289273883493675009, + "id_str": "1289273883493675009", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "possibly_sensitive": False, + "retweet_count": 380, + "retweeted": False, + "source": 'Buffer', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Sun Dec 06 16:07:01 +0000 2009", + "default_profile": False, + "default_profile_image": False, + "description": "The most unimportant things you'll never need to know.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "uber-facts.com", + "expanded_url": "http://uber-facts.com/", + "indices": [0, 23], + "url": "https://t.co/3ycpGqEL9n", + } + ] + }, + }, + "favourites_count": 1297, + "follow_request_sent": None, + "followers_count": 13810392, + "following": None, + "friends_count": 1, + "geo_enabled": True, + "has_extended_profile": False, + "id": 95023423, + "id_str": "95023423", + "is_translation_enabled": True, + "is_translator": False, + "lang": None, + "listed_count": 15141, + "location": "Worldwide!", + "name": "UberFacts", + "notifications": None, + "profile_background_color": "C0DEED", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme1/bg.png", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme1/bg.png", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/95023423/1587338728", + "profile_image_url": "http://pbs.twimg.com/profile_images/615696617165885440/JDbUuo9H_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/615696617165885440/JDbUuo9H_normal.jpg", + "profile_link_color": "0D9BA8", + "profile_sidebar_border_color": "000000", + "profile_sidebar_fill_color": "FFFFFF", + "profile_text_color": "000000", + "profile_use_background_image": True, + "protected": False, + "screen_name": "UberFacts", + "statuses_count": 202253, + "time_zone": None, + "translator_type": "regular", + "url": "https://t.co/3ycpGqEL9n", + "utc_offset": None, + "verified": True, + }, + }, + "quoted_status_id": 1289273883493675009, + "quoted_status_id_str": "1289273883493675009", + "quoted_status_permalink": { + "display": "twitter.com/UberFacts/stat\u2026", + "expanded": "https://twitter.com/UberFacts/status/1289273883493675009", + "url": "https://t.co/LLPVr8oU7F", + }, + "retweet_count": 24, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4588, + "follow_request_sent": None, + "followers_count": 106169, + "following": None, + "friends_count": 201, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 890, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6210, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, +] + +# contains tweets with "extended_entities" keys which contains native media objects +# which are in this case of the type "animated_gif" +gif_mock = [ + { + "contributors": None, + "coordinates": None, + "created_at": "Fri Jul 31 23:10:55 +0000 2020", + "display_text_range": [12, 12], + "entities": { + "hashtags": [], + "media": [ + { + "display_url": "pic.twitter.com/wxvioLCJ6h", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1289337776140296193/photo/1", + "id": 1289337769521606656, + "id_str": "1289337769521606656", + "indices": [13, 36], + "media_url": "http://pbs.twimg.com/tweet_video_thumb/EeSl3sPUcAAyE4J.jpg", + "media_url_https": "https://pbs.twimg.com/tweet_video_thumb/EeSl3sPUcAAyE4J.jpg", + "sizes": { + "large": {"h": 210, "resize": "fit", "w": 250}, + "medium": {"h": 210, "resize": "fit", "w": 250}, + "small": {"h": 210, "resize": "fit", "w": 250}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/wxvioLCJ6h", + } + ], + "symbols": [], + "urls": [], + "user_mentions": [ + { + "id": 994361231057346561, + "id_str": "994361231057346561", + "indices": [0, 12], + "name": "Xenosystems", + "screen_name": "Xenosystems", + } + ], + }, + "extended_entities": { + "media": [ + { + "display_url": "pic.twitter.com/wxvioLCJ6h", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1289337776140296193/photo/1", + "id": 1289337769521606656, + "id_str": "1289337769521606656", + "indices": [13, 36], + "media_url": "http://pbs.twimg.com/tweet_video_thumb/EeSl3sPUcAAyE4J.jpg", + "media_url_https": "https://pbs.twimg.com/tweet_video_thumb/EeSl3sPUcAAyE4J.jpg", + "sizes": { + "large": {"h": 210, "resize": "fit", "w": 250}, + "medium": {"h": 210, "resize": "fit", "w": 250}, + "small": {"h": 210, "resize": "fit", "w": 250}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "animated_gif", + "url": "https://t.co/wxvioLCJ6h", + "video_info": { + "aspect_ratio": [25, 21], + "variants": [ + { + "bitrate": 0, + "content_type": "video/mp4", + "url": "https://video.twimg.com/tweet_video/EeSl3sPUcAAyE4J.mp4", + } + ], + }, + } + ] + }, + "favorite_count": 13, + "favorited": False, + "full_text": "@Xenosystems https://t.co/wxvioLCJ6h", + "geo": None, + "id": 1289337776140296193, + "id_str": "1289337776140296193", + "in_reply_to_screen_name": "Xenosystems", + "in_reply_to_status_id": 1289324787815178242, + "in_reply_to_status_id_str": "1289324787815178242", + "in_reply_to_user_id": 994361231057346561, + "in_reply_to_user_id_str": "994361231057346561", + "is_quote_status": False, + "lang": "und", + "place": None, + "possibly_sensitive": False, + "retweet_count": 1, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4588, + "follow_request_sent": None, + "followers_count": 106169, + "following": None, + "friends_count": 201, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 890, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6210, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, + { + "contributors": None, + "coordinates": None, + "created_at": "Thu Jul 30 22:30:29 +0000 2020", + "display_text_range": [12, 12], + "entities": { + "hashtags": [], + "media": [ + { + "display_url": "pic.twitter.com/DTbhK1pTc4", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1288965215648849920/photo/1", + "id": 1288965209596420097, + "id_str": "1288965209596420097", + "indices": [13, 36], + "media_url": "http://pbs.twimg.com/tweet_video_thumb/EeNTB2XU4AE-z5Y.jpg", + "media_url_https": "https://pbs.twimg.com/tweet_video_thumb/EeNTB2XU4AE-z5Y.jpg", + "sizes": { + "large": {"h": 278, "resize": "fit", "w": 498}, + "medium": {"h": 278, "resize": "fit", "w": 498}, + "small": {"h": 278, "resize": "fit", "w": 498}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/DTbhK1pTc4", + } + ], + "symbols": [], + "urls": [], + "user_mentions": [ + { + "id": 994361231057346561, + "id_str": "994361231057346561", + "indices": [0, 12], + "name": "Xenosystems", + "screen_name": "Xenosystems", + } + ], + }, + "extended_entities": { + "media": [ + { + "display_url": "pic.twitter.com/DTbhK1pTc4", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1288965215648849920/photo/1", + "id": 1288965209596420097, + "id_str": "1288965209596420097", + "indices": [13, 36], + "media_url": "http://pbs.twimg.com/tweet_video_thumb/EeNTB2XU4AE-z5Y.jpg", + "media_url_https": "https://pbs.twimg.com/tweet_video_thumb/EeNTB2XU4AE-z5Y.jpg", + "sizes": { + "large": {"h": 278, "resize": "fit", "w": 498}, + "medium": {"h": 278, "resize": "fit", "w": 498}, + "small": {"h": 278, "resize": "fit", "w": 498}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "animated_gif", + "url": "https://t.co/DTbhK1pTc4", + "video_info": { + "aspect_ratio": [249, 139], + "variants": [ + { + "bitrate": 0, + "content_type": "video/mp4", + "url": "https://video.twimg.com/tweet_video/EeNTB2XU4AE-z5Y.mp4", + } + ], + }, + } + ] + }, + "favorite_count": 20, + "favorited": False, + "full_text": "@Xenosystems https://t.co/DTbhK1pTc4", + "geo": None, + "id": 1288965215648849920, + "id_str": "1288965215648849920", + "in_reply_to_screen_name": "Xenosystems", + "in_reply_to_status_id": 1288960722349719554, + "in_reply_to_status_id_str": "1288960722349719554", + "in_reply_to_user_id": 994361231057346561, + "in_reply_to_user_id_str": "994361231057346561", + "is_quote_status": False, + "lang": "und", + "place": None, + "possibly_sensitive": False, + "retweet_count": 0, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4588, + "follow_request_sent": None, + "followers_count": 106169, + "following": None, + "friends_count": 201, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 890, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6210, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, +] + +unsanitized_mock = [ + { + "contributors": None, + "coordinates": None, + "created_at": "Fri Aug 07 00:17:05 +0000 2020", + "display_text_range": [11, 59], + "entities": { + "hashtags": [], + "symbols": [], + "urls": [ + { + "display_url": "youtu.be/rDy7tPf6CT8", + "expanded_url": "https://youtu.be/rDy7tPf6CT8", + "indices": [36, 59], + "url": "https://t.co/trAcIxBMlX", + } + ], + "user_mentions": [ + { + "id": 975844884606275587, + "id_str": "975844884606275587", + "indices": [0, 10], + "name": "ArieNeo", + "screen_name": "ArieNeoSC", + } + ], + }, + "favorite_count": 19, + "favorited": False, + "full_text": "@ArieNeoSC Here you go, goodnight!\n\nhttps://t.co/trAcIxBMlX
      ", + "geo": None, + "id": 1291528756373286914, + "id_str": "1291528756373286914", + "in_reply_to_screen_name": "ArieNeoSC", + "in_reply_to_status_id": 1291507356313038850, + "in_reply_to_status_id_str": "1291507356313038850", + "in_reply_to_user_id": 975844884606275587, + "in_reply_to_user_id_str": "975844884606275587", + "is_quote_status": False, + "lang": "en", + "place": None, + "possibly_sensitive": False, + "retweet_count": 5, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4588, + "follow_request_sent": None, + "followers_count": 106169, + "following": None, + "friends_count": 201, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 890, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6210, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + } +] diff --git a/src/newsreader/news/collection/tests/twitter/builder/tests.py b/src/newsreader/news/collection/tests/twitter/builder/tests.py new file mode 100644 index 0000000..37d7ad7 --- /dev/null +++ b/src/newsreader/news/collection/tests/twitter/builder/tests.py @@ -0,0 +1,412 @@ +from datetime import datetime +from unittest.mock import Mock + +from django.test import TestCase +from django.utils.safestring import mark_safe + +import pytz + +from ftfy import fix_text + +from newsreader.news.collection.tests.factories import TwitterTimelineFactory +from newsreader.news.collection.tests.twitter.builder.mocks import ( + gif_mock, + image_mock, + quoted_mock, + retweet_mock, + simple_mock, + unsanitized_mock, + video_mock, + video_without_bitrate_mock, +) +from newsreader.news.collection.twitter import TWITTER_URL, TwitterBuilder +from newsreader.news.collection.utils import truncate_text +from newsreader.news.core.models import Post +from newsreader.news.core.tests.factories import PostFactory + + +class TwitterBuilderTestCase(TestCase): + def setUp(self): + self.maxDiff = None + + def test_simple_post(self): + builder = TwitterBuilder + + profile = TwitterTimelineFactory(screen_name="RobertsSpaceInd") + mock_stream = Mock(rule=profile) + + with builder(simple_mock, mock_stream) as builder: + builder.build() + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual( + ("1291528756373286914", "1288550304095416320"), posts.keys() + ) + + post = posts["1291528756373286914"] + + full_text = ( + "@ArieNeoSC Here you go, goodnight!\n\n" + """https://t.co/trAcIxBMlX""" + ) + + self.assertEquals(post.rule, profile) + self.assertEquals( + post.title, + truncate_text( + Post, + "title", + "@ArieNeoSC Here you go, goodnight!\n\nhttps://t.co/trAcIxBMlX", + ), + ) + self.assertEquals(post.body, mark_safe(full_text)) + + self.assertEquals(post.author, "RobertsSpaceInd") + self.assertEquals( + post.url, f"{TWITTER_URL}/RobertsSpaceInd/status/1291528756373286914" + ) + self.assertEquals( + post.publication_date, pytz.utc.localize(datetime(2020, 8, 7, 0, 17, 5)) + ) + + post = posts["1288550304095416320"] + + full_text = "@RelicCcb Hi Christoper, we have checked the status of your investigation and it is still ongoing." + + self.assertEquals(post.rule, profile) + self.assertEquals(post.title, truncate_text(Post, "title", full_text)) + self.assertEquals(post.body, mark_safe(full_text)) + + self.assertEquals(post.author, "RobertsSpaceInd") + self.assertEquals( + post.url, f"{TWITTER_URL}/RobertsSpaceInd/status/1288550304095416320" + ) + self.assertEquals( + post.publication_date, pytz.utc.localize(datetime(2020, 7, 29, 19, 1, 47)) + ) + + # note that only one media type can be uploaded to an Tweet + # see https://developer.twitter.com/en/docs/tweets/data-dictionary/overview/extended-entities-object + def test_images_in_post(self): + builder = TwitterBuilder + + profile = TwitterTimelineFactory(screen_name="RobertsSpaceInd") + mock_stream = Mock(rule=profile) + + with builder(image_mock, mock_stream) as builder: + builder.build() + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual(("1269039237166321664",), posts.keys()) + + post = posts["1269039237166321664"] + + self.assertEquals(post.rule, profile) + self.assertEquals(post.title, "_ https://t.co/VjEeDrL1iA") + + self.assertEquals(post.author, "RobertsSpaceInd") + self.assertEquals( + post.url, f"{TWITTER_URL}/RobertsSpaceInd/status/1269039237166321664" + ) + self.assertEquals( + post.publication_date, pytz.utc.localize(datetime(2020, 6, 5, 22, 51, 46)) + ) + + self.assertInHTML( + """https://t.co/VjEeDrL1iA""", + post.body, + count=1, + ) + self.assertInHTML( + """
      1269039233072689152
      """, + post.body, + count=1, + ) + self.assertInHTML( + """
      1269039233068527618
      """, + post.body, + count=1, + ) + + def test_videos_in_post(self): + builder = TwitterBuilder + + profile = TwitterTimelineFactory(screen_name="RobertsSpaceInd") + mock_stream = Mock(rule=profile) + + with builder(video_mock, mock_stream) as builder: + builder.build() + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual( + ("1291080532361527296", "1291079386821582849"), posts.keys() + ) + + post = posts["1291080532361527296"] + + full_text = fix_text( + "Small enough to access hard-to-reach ore deposits, but with enough" + " power to get through the tough jobs, Greycat\u2019s ROC perfectly" + " complements any mining operation. \n\nDetails:" + """ https://t.co/2aH7qdOfSk""" + """ https://t.co/mZ8CAuq3SH""" + ) + + self.assertEquals(post.rule, profile) + self.assertEquals( + post.title, + truncate_text( + Post, + "title", + fix_text( + "Small enough to access hard-to-reach ore deposits, but with enough" + " power to get through the tough jobs, Greycat\u2019s ROC perfectly" + " complements any mining operation. \n\nDetails:" + " https://t.co/2aH7qdOfSk https://t.co/mZ8CAuq3SH" + ), + ), + ) + + self.assertEquals(post.author, "RobertsSpaceInd") + self.assertEquals( + post.url, f"{TWITTER_URL}/RobertsSpaceInd/status/1291080532361527296" + ) + self.assertEquals( + post.publication_date, pytz.utc.localize(datetime(2020, 8, 5, 18, 36, 0)) + ) + + self.assertIn(full_text, post.body) + self.assertInHTML( + """
      """, + post.body, + count=1, + ) + + def test_video_without_bitrate(self): + builder = TwitterBuilder + + profile = TwitterTimelineFactory(screen_name="RobertsSpaceInd") + mock_stream = Mock(rule=profile) + + with builder(video_without_bitrate_mock, mock_stream) as builder: + builder.build() + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual(("1291080532361527296",), posts.keys()) + + post = posts["1291080532361527296"] + + self.assertInHTML( + """
      """, + post.body, + count=1, + ) + + def test_GIFs_in_post(self): + builder = TwitterBuilder + + profile = TwitterTimelineFactory(screen_name="RobertsSpaceInd") + mock_stream = Mock(rule=profile) + + with builder(gif_mock, mock_stream) as builder: + builder.build() + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual( + ("1289337776140296193", "1288965215648849920"), posts.keys() + ) + + post = posts["1289337776140296193"] + + self.assertInHTML( + """
      """, + post.body, + count=1, + ) + + self.assertIn( + """@Xenosystems https://t.co/wxvioLCJ6h""", + post.body, + ) + + def test_retweet_post(self): + builder = TwitterBuilder + + profile = TwitterTimelineFactory(screen_name="RobertsSpaceInd") + mock_stream = Mock(rule=profile) + + with builder(retweet_mock, mock_stream) as builder: + builder.build() + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual( + ("1291117030486106112", "1288825524878336000"), posts.keys() + ) + + post = posts["1291117030486106112"] + + self.assertIn( + fix_text( + "RT @Narayan_N7: New video! #StarCitizen 3.9 vs. 3.10 comparison!\nSo," + " the patch 3.10 came out, which brought us quite a lot of changes!\ud83d\ude42\nPle\u2026" + ), + post.body, + ) + + self.assertIn( + fix_text( + "Original tweet: New video! #StarCitizen 3.9 vs. 3.10 comparison!\nSo, the patch" + " 3.10 came out, which brought us quite a lot of changes!\ud83d\ude42\nPlease," + " share it with your friends!\ud83d\ude4f\n\nEnjoy watching and stay safe!" + " \u2764\ufe0f\u263a\ufe0f\n@RobertsSpaceInd\n\n@CloudImperium\n\n" + """https://t.co/j4QahHzbw4""" + ), + post.body, + ) + + def test_quoted_post(self): + builder = TwitterBuilder + + profile = TwitterTimelineFactory(screen_name="RobertsSpaceInd") + mock_stream = Mock(rule=profile) + + with builder(quoted_mock, mock_stream) as builder: + builder.build() + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual( + ("1290801039075979264", "1289320160021495809"), posts.keys() + ) + + post = posts["1290801039075979264"] + + self.assertIn( + fix_text( + "Bonne nuit \ud83c\udf3a\ud83d\udeeb" + """ https://t.co/WyznJwCJLp""" + ), + post.body, + ) + + self.assertIn( + fix_text( + "Quoted tweet: #Starcitizen Le jeu est beau. Bonne nuit" + """ @RobertsSpaceInd https://t.co/xCXun68V3r""" + ), + post.body, + ) + + def test_empty_data(self): + builder = TwitterBuilder + + profile = TwitterTimelineFactory(screen_name="RobertsSpaceInd") + mock_stream = Mock(rule=profile) + + with builder([], mock_stream) as builder: + builder.build() + builder.save() + + self.assertEquals(Post.objects.count(), 0) + + def test_html_sanitizing(self): + builder = TwitterBuilder + + profile = TwitterTimelineFactory(screen_name="RobertsSpaceInd") + mock_stream = Mock(rule=profile) + + with builder(unsanitized_mock, mock_stream) as builder: + builder.build() + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual(("1291528756373286914",), posts.keys()) + + post = posts["1291528756373286914"] + + full_text = ( + "@ArieNeoSC Here you go, goodnight!\n\n" + """https://t.co/trAcIxBMlX""" + "
      " + ) + + self.assertEquals(post.rule, profile) + self.assertEquals( + post.title, + truncate_text( + Post, + "title", + "@ArieNeoSC Here you go, goodnight!\n\nhttps://t.co/trAcIxBMlX" + "
      ", + ), + ) + self.assertEquals(post.body, mark_safe(full_text)) + + self.assertInHTML("", post.body, count=0) + self.assertInHTML("
      ", post.body, count=1) + + self.assertInHTML("", post.title, count=0) + self.assertInHTML("
      ", post.title, count=1) + + def test_urlize_on_urls(self): + builder = TwitterBuilder + + profile = TwitterTimelineFactory(screen_name="RobertsSpaceInd") + mock_stream = Mock(rule=profile) + + with builder(simple_mock, mock_stream) as builder: + builder.build() + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual( + ("1291528756373286914", "1288550304095416320"), posts.keys() + ) + + post = posts["1291528756373286914"] + + full_text = ( + "@ArieNeoSC Here you go, goodnight!\n\n" + """https://t.co/trAcIxBMlX""" + ) + + self.assertEquals(post.rule, profile) + self.assertEquals( + post.title, + truncate_text( + Post, + "title", + "@ArieNeoSC Here you go, goodnight!\n\nhttps://t.co/trAcIxBMlX", + ), + ) + self.assertEquals(post.body, mark_safe(full_text)) + + def test_existing_posts(self): + builder = TwitterBuilder + + profile = TwitterTimelineFactory(screen_name="RobertsSpaceInd") + mock_stream = Mock(rule=profile) + + PostFactory(rule=profile, remote_identifier="1291528756373286914") + PostFactory(rule=profile, remote_identifier="1288550304095416320") + + with builder(simple_mock, mock_stream) as builder: + builder.build() + builder.save() + + self.assertEquals(Post.objects.count(), 2) diff --git a/src/newsreader/news/collection/tests/twitter/client/__init__.py b/src/newsreader/news/collection/tests/twitter/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/twitter/client/mocks.py b/src/newsreader/news/collection/tests/twitter/client/mocks.py new file mode 100644 index 0000000..1b7c6a2 --- /dev/null +++ b/src/newsreader/news/collection/tests/twitter/client/mocks.py @@ -0,0 +1,225 @@ +# retrieved with: +# curl -X GET -H "Authorization: Bearer " "https://api.twitter.com/1.1/statuses/user_timeline.json?screen_name=RobertsSpaceInd&tweet_mode=extended" | python3 -m json.tool --sort-keys + +simple_mock = [ + { + "contributors": None, + "coordinates": None, + "created_at": "Fri Sep 18 20:32:22 +0000 2020", + "display_text_range": [0, 111], + "entities": { + "hashtags": [{"indices": [26, 41], "text": "SCShipShowdown"}], + "symbols": [], + "urls": [], + "user_mentions": [], + }, + "favorite_count": 54, + "favorited": False, + "full_text": "It's a close match-up for #SCShipShowdown today! Which Aegis ship do you think will make it to the Semi-Finals?", + "geo": None, + "id": 1307054882210435074, + "id_str": "1307054882210435074", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "retweet_count": 9, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4831, + "follow_request_sent": None, + "followers_count": 106971, + "following": None, + "friends_count": 204, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 893, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6368, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, + { + "contributors": None, + "coordinates": None, + "created_at": "Fri Sep 18 18:50:11 +0000 2020", + "display_text_range": [0, 271], + "entities": { + "hashtags": [{"indices": [211, 218], "text": "Twitch"}], + "media": [ + { + "display_url": "pic.twitter.com/Cey5JpR1i9", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1307029168941461504/photo/1", + "id": 1307028141697765376, + "id_str": "1307028141697765376", + "indices": [272, 295], + "media_url": "http://pbs.twimg.com/media/EiN_K4FVkAAGBcr.jpg", + "media_url_https": "https://pbs.twimg.com/media/EiN_K4FVkAAGBcr.jpg", + "sizes": { + "large": {"h": 1090, "resize": "fit", "w": 1920}, + "medium": {"h": 681, "resize": "fit", "w": 1200}, + "small": {"h": 386, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/Cey5JpR1i9", + } + ], + "symbols": [], + "urls": [ + { + "display_url": "twitch.tv/starcitizen", + "expanded_url": "http://twitch.tv/starcitizen", + "indices": [248, 271], + "url": "https://t.co/2AdNovhpFW", + } + ], + "user_mentions": [], + }, + "extended_entities": { + "media": [ + { + "display_url": "pic.twitter.com/Cey5JpR1i9", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1307029168941461504/photo/1", + "id": 1307028141697765376, + "id_str": "1307028141697765376", + "indices": [272, 295], + "media_url": "http://pbs.twimg.com/media/EiN_K4FVkAAGBcr.jpg", + "media_url_https": "https://pbs.twimg.com/media/EiN_K4FVkAAGBcr.jpg", + "sizes": { + "large": {"h": 1090, "resize": "fit", "w": 1920}, + "medium": {"h": 681, "resize": "fit", "w": 1200}, + "small": {"h": 386, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/Cey5JpR1i9", + } + ] + }, + "favorite_count": 90, + "favorited": False, + "full_text": "We\u2019re welcoming members of our Builds, Publishes and Platform teams on Star Citizen Live to talk about the process involved in bringing everyone\u2019s work together and getting it out into your hands. Going live on #Twitch in 10 minutes. \ud83c\udfa5\ud83d\udd34 \n\nTune in: https://t.co/2AdNovhpFW https://t.co/Cey5JpR1i9", + "geo": None, + "id": 1307029168941461504, + "id_str": "1307029168941461504", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "possibly_sensitive": False, + "retweet_count": 13, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4831, + "follow_request_sent": None, + "followers_count": 106971, + "following": None, + "friends_count": 204, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 893, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6368, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, +] diff --git a/src/newsreader/news/collection/tests/twitter/client/tests.py b/src/newsreader/news/collection/tests/twitter/client/tests.py new file mode 100644 index 0000000..387ffef --- /dev/null +++ b/src/newsreader/news/collection/tests/twitter/client/tests.py @@ -0,0 +1,162 @@ +from unittest.mock import Mock, patch +from uuid import uuid4 + +from django.test import TestCase +from django.utils.lorem_ipsum import words + +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamException, + StreamNotFoundException, + StreamParseException, + StreamTimeOutException, + StreamTooManyException, +) +from newsreader.news.collection.tests.factories import TwitterTimelineFactory +from newsreader.news.collection.twitter import TwitterClient + +from .mocks import simple_mock + + +class TwitterClientTestCase(TestCase): + def setUp(self): + patched_read = patch("newsreader.news.collection.twitter.TwitterStream.read") + self.mocked_read = patched_read.start() + + def tearDown(self): + patch.stopall() + + def test_simple(self): + timeline = TwitterTimelineFactory() + mock_stream = Mock(rule=timeline) + + self.mocked_read.return_value = (simple_mock, mock_stream) + + with TwitterClient([timeline]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertEquals(data, simple_mock) + self.assertEquals(stream, mock_stream) + + self.mocked_read.assert_called() + + def test_client_catches_stream_exception(self): + timeline = TwitterTimelineFactory() + + self.mocked_read.side_effect = StreamException(message="Stream exception") + + with TwitterClient([timeline]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertIsNone(data) + self.assertIsNone(stream) + self.assertEquals(stream.rule.error, "Stream exception") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called() + + def test_client_catches_stream_not_found_exception(self): + timeline = TwitterTimelineFactory.create() + + self.mocked_read.side_effect = StreamNotFoundException( + message="Stream not found" + ) + + with TwitterClient([timeline]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertIsNone(data) + self.assertIsNone(stream) + self.assertEquals(stream.rule.error, "Stream not found") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called() + + def test_client_catches_stream_denied_exception(self): + user = UserFactory( + twitter_oauth_token=str(uuid4()), twitter_oauth_token_secret=str(uuid4()) + ) + timeline = TwitterTimelineFactory(user=user) + + self.mocked_read.side_effect = StreamDeniedException(message="Token expired") + + with TwitterClient([timeline]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertIsNone(data) + self.assertIsNone(stream) + self.assertEquals(stream.rule.error, "Token expired") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called() + + user.refresh_from_db() + timeline.refresh_from_db() + + self.assertIsNone(user.twitter_oauth_token) + self.assertIsNone(user.twitter_oauth_token_secret) + + def test_client_catches_stream_timed_out_exception(self): + timeline = TwitterTimelineFactory() + + self.mocked_read.side_effect = StreamTimeOutException( + message="Stream timed out" + ) + + with TwitterClient([timeline]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertIsNone(data) + self.assertIsNone(stream) + self.assertEquals(stream.rule.error, "Stream timed out") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called() + + def test_client_catches_stream_too_many_exception(self): + timeline = TwitterTimelineFactory() + + self.mocked_read.side_effect = StreamTooManyException + + with TwitterClient([timeline]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertIsNone(data) + self.assertIsNone(stream) + self.assertEquals(stream.rule.error, "Too many requests") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called() + + def test_client_catches_stream_parse_exception(self): + timeline = TwitterTimelineFactory() + + self.mocked_read.side_effect = StreamParseException( + message="Stream could not be parsed" + ) + + with TwitterClient([timeline]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertIsNone(data) + self.assertIsNone(stream) + self.assertEquals(stream.rule.error, "Stream could not be parsed") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called() + + def test_client_catches_long_exception_text(self): + timeline = TwitterTimelineFactory() + mock_stream = Mock(rule=timeline) + + self.mocked_read.side_effect = StreamParseException(message=words(1000)) + + with TwitterClient([timeline]) as client: + for data, stream in client: + self.assertIsNone(data) + self.assertIsNone(stream) + self.assertEquals(len(stream.rule.error), 1024) + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called() diff --git a/src/newsreader/news/collection/tests/twitter/collector/__init__.py b/src/newsreader/news/collection/tests/twitter/collector/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/twitter/collector/mocks.py b/src/newsreader/news/collection/tests/twitter/collector/mocks.py new file mode 100644 index 0000000..c57f9cf --- /dev/null +++ b/src/newsreader/news/collection/tests/twitter/collector/mocks.py @@ -0,0 +1,227 @@ +# retrieved with: +# curl -X GET -H "Authorization: Bearer " "https://api.twitter.com/1.1/statuses/user_timeline.json?screen_name=RobertsSpaceInd&tweet_mode=extended" | python3 -m json.tool --sort-keys + +simple_mock = [ + { + "contributors": None, + "coordinates": None, + "created_at": "Fri Sep 18 20:32:22 +0000 2020", + "display_text_range": [0, 111], + "entities": { + "hashtags": [{"indices": [26, 41], "text": "SCShipShowdown"}], + "symbols": [], + "urls": [], + "user_mentions": [], + }, + "favorite_count": 54, + "favorited": False, + "full_text": "It's a close match-up for #SCShipShowdown today! Which Aegis ship do you think will make it to the Semi-Finals?", + "geo": None, + "id": 1307054882210435074, + "id_str": "1307054882210435074", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "retweet_count": 9, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4831, + "follow_request_sent": None, + "followers_count": 106971, + "following": None, + "friends_count": 204, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 893, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6368, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, + { + "contributors": None, + "coordinates": None, + "created_at": "Fri Sep 18 18:50:11 +0000 2020", + "display_text_range": [0, 271], + "entities": { + "hashtags": [{"indices": [211, 218], "text": "Twitch"}], + "media": [ + { + "display_url": "pic.twitter.com/Cey5JpR1i9", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1307029168941461504/photo/1", + "id": 1307028141697765376, + "id_str": "1307028141697765376", + "indices": [272, 295], + "media_url": "http://pbs.twimg.com/media/EiN_K4FVkAAGBcr.jpg", + "media_url_https": "https://pbs.twimg.com/media/EiN_K4FVkAAGBcr.jpg", + "sizes": { + "large": {"h": 1090, "resize": "fit", "w": 1920}, + "medium": {"h": 681, "resize": "fit", "w": 1200}, + "small": {"h": 386, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/Cey5JpR1i9", + } + ], + "symbols": [], + "urls": [ + { + "display_url": "twitch.tv/starcitizen", + "expanded_url": "http://twitch.tv/starcitizen", + "indices": [248, 271], + "url": "https://t.co/2AdNovhpFW", + } + ], + "user_mentions": [], + }, + "extended_entities": { + "media": [ + { + "display_url": "pic.twitter.com/Cey5JpR1i9", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1307029168941461504/photo/1", + "id": 1307028141697765376, + "id_str": "1307028141697765376", + "indices": [272, 295], + "media_url": "http://pbs.twimg.com/media/EiN_K4FVkAAGBcr.jpg", + "media_url_https": "https://pbs.twimg.com/media/EiN_K4FVkAAGBcr.jpg", + "sizes": { + "large": {"h": 1090, "resize": "fit", "w": 1920}, + "medium": {"h": 681, "resize": "fit", "w": 1200}, + "small": {"h": 386, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/Cey5JpR1i9", + } + ] + }, + "favorite_count": 90, + "favorited": False, + "full_text": "We\u2019re welcoming members of our Builds, Publishes and Platform teams on Star Citizen Live to talk about the process involved in bringing everyone\u2019s work together and getting it out into your hands. Going live on #Twitch in 10 minutes. \ud83c\udfa5\ud83d\udd34 \n\nTune in: https://t.co/2AdNovhpFW https://t.co/Cey5JpR1i9", + "geo": None, + "id": 1307029168941461504, + "id_str": "1307029168941461504", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "possibly_sensitive": False, + "retweet_count": 13, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4831, + "follow_request_sent": None, + "followers_count": 106971, + "following": None, + "friends_count": 204, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 893, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6368, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, +] + +empty_mock = [] diff --git a/src/newsreader/news/collection/tests/twitter/collector/tests.py b/src/newsreader/news/collection/tests/twitter/collector/tests.py new file mode 100644 index 0000000..766e971 --- /dev/null +++ b/src/newsreader/news/collection/tests/twitter/collector/tests.py @@ -0,0 +1,180 @@ +from datetime import datetime +from unittest.mock import patch +from uuid import uuid4 + +from django.test import TestCase +from django.utils import timezone + +import pytz + +from freezegun import freeze_time +from ftfy import fix_text + +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamForbiddenException, + StreamNotFoundException, + StreamTimeOutException, +) +from newsreader.news.collection.tests.factories import TwitterTimelineFactory +from newsreader.news.collection.tests.twitter.collector.mocks import ( + empty_mock, + simple_mock, +) +from newsreader.news.collection.twitter import TWITTER_URL, TwitterCollector +from newsreader.news.collection.utils import truncate_text +from newsreader.news.core.models import Post + + +@freeze_time("2020-09-26 14:40:00") +class TwitterCollectorTestCase(TestCase): + def setUp(self): + patched_get = patch("newsreader.news.collection.twitter.fetch") + self.mocked_fetch = patched_get.start() + + patched_parse = patch("newsreader.news.collection.twitter.TwitterStream.parse") + self.mocked_parse = patched_parse.start() + + def tearDown(self): + patch.stopall() + + def test_simple_batch(self): + self.mocked_parse.return_value = simple_mock + + timeline = TwitterTimelineFactory( + user__twitter_oauth_token=str(uuid4()), + user__twitter_oauth_token_secret=str(uuid4()), + screen_name="RobertsSpaceInd", + enabled=True, + ) + + collector = TwitterCollector() + collector.collect(rules=[timeline]) + + self.assertCountEqual( + Post.objects.values_list("remote_identifier", flat=True), + ("1307054882210435074", "1307029168941461504"), + ) + + self.assertEquals(timeline.succeeded, True) + self.assertEquals(timeline.last_run, timezone.now()) + self.assertIsNone(timeline.error) + + post = Post.objects.get( + remote_identifier="1307054882210435074", + rule__type=RuleTypeChoices.twitter_timeline, + ) + + self.assertEquals( + post.publication_date, pytz.utc.localize(datetime(2020, 9, 18, 20, 32, 22)) + ) + + title = truncate_text( + Post, + "title", + "It's a close match-up for #SCShipShowdown today! Which Aegis ship" + " do you think will make it to the Semi-Finals?", + ) + + self.assertEquals(post.author, "RobertsSpaceInd") + self.assertEquals(post.title, title) + self.assertEquals( + post.url, f"{TWITTER_URL}/RobertsSpaceInd/status/1307054882210435074" + ) + + post = Post.objects.get( + remote_identifier="1307029168941461504", + rule__type=RuleTypeChoices.twitter_timeline, + ) + + self.assertEquals( + post.publication_date, pytz.utc.localize(datetime(2020, 9, 18, 18, 50, 11)) + ) + + body = fix_text( + "We\u2019re welcoming members of our Builds, Publishes and Platform" + " teams on Star Citizen Live to talk about the process involved in" + " bringing everyone\u2019s work together and getting it out into your" + " hands. Going live on #Twitch in 10 minutes." + " \ud83c\udfa5\ud83d\udd34 \n\nTune in:" + " https://t.co/2AdNovhpFW https://t.co/Cey5JpR1i9" + ) + + title = truncate_text(Post, "title", body) + + self.assertEquals(post.author, "RobertsSpaceInd") + self.assertEquals(post.title, title) + self.assertEquals( + post.url, f"{TWITTER_URL}/RobertsSpaceInd/status/1307029168941461504" + ) + + def test_empty_batch(self): + self.mocked_parse.return_value = empty_mock + + timeline = TwitterTimelineFactory() + + collector = TwitterCollector() + collector.collect(rules=[timeline]) + + self.assertEquals(Post.objects.count(), 0) + + self.assertEquals(timeline.succeeded, True) + self.assertEquals(timeline.last_run, timezone.now()) + self.assertIsNone(timeline.error) + + def test_not_found(self): + self.mocked_fetch.side_effect = StreamNotFoundException + + timeline = TwitterTimelineFactory() + + collector = TwitterCollector() + collector.collect(rules=[timeline]) + + self.assertEquals(Post.objects.count(), 0) + self.assertEquals(timeline.succeeded, False) + self.assertEquals(timeline.error, "Stream not found") + + def test_denied(self): + self.mocked_fetch.side_effect = StreamDeniedException + + timeline = TwitterTimelineFactory( + user__twitter_oauth_token=str(uuid4()), + user__twitter_oauth_token_secret=str(uuid4()), + ) + + collector = TwitterCollector() + collector.collect(rules=[timeline]) + + self.assertEquals(Post.objects.count(), 0) + self.assertEquals(timeline.succeeded, False) + self.assertEquals(timeline.error, "Stream does not have sufficient permissions") + + user = timeline.user + + self.assertIsNone(user.twitter_oauth_token) + self.assertIsNone(user.twitter_oauth_token_secret) + + def test_forbidden(self): + self.mocked_fetch.side_effect = StreamForbiddenException + + timeline = TwitterTimelineFactory() + + collector = TwitterCollector() + collector.collect(rules=[timeline]) + + self.assertEquals(Post.objects.count(), 0) + self.assertEquals(timeline.succeeded, False) + self.assertEquals(timeline.error, "Stream forbidden") + + def test_timed_out(self): + self.mocked_fetch.side_effect = StreamTimeOutException + + timeline = TwitterTimelineFactory() + + collector = TwitterCollector() + collector.collect(rules=[timeline]) + + self.assertEquals(Post.objects.count(), 0) + self.assertEquals(timeline.succeeded, False) + self.assertEquals(timeline.error, "Stream timed out") diff --git a/src/newsreader/news/collection/tests/twitter/stream/__init__.py b/src/newsreader/news/collection/tests/twitter/stream/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/twitter/stream/mocks.py b/src/newsreader/news/collection/tests/twitter/stream/mocks.py new file mode 100644 index 0000000..1b7c6a2 --- /dev/null +++ b/src/newsreader/news/collection/tests/twitter/stream/mocks.py @@ -0,0 +1,225 @@ +# retrieved with: +# curl -X GET -H "Authorization: Bearer " "https://api.twitter.com/1.1/statuses/user_timeline.json?screen_name=RobertsSpaceInd&tweet_mode=extended" | python3 -m json.tool --sort-keys + +simple_mock = [ + { + "contributors": None, + "coordinates": None, + "created_at": "Fri Sep 18 20:32:22 +0000 2020", + "display_text_range": [0, 111], + "entities": { + "hashtags": [{"indices": [26, 41], "text": "SCShipShowdown"}], + "symbols": [], + "urls": [], + "user_mentions": [], + }, + "favorite_count": 54, + "favorited": False, + "full_text": "It's a close match-up for #SCShipShowdown today! Which Aegis ship do you think will make it to the Semi-Finals?", + "geo": None, + "id": 1307054882210435074, + "id_str": "1307054882210435074", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "retweet_count": 9, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4831, + "follow_request_sent": None, + "followers_count": 106971, + "following": None, + "friends_count": 204, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 893, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6368, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, + { + "contributors": None, + "coordinates": None, + "created_at": "Fri Sep 18 18:50:11 +0000 2020", + "display_text_range": [0, 271], + "entities": { + "hashtags": [{"indices": [211, 218], "text": "Twitch"}], + "media": [ + { + "display_url": "pic.twitter.com/Cey5JpR1i9", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1307029168941461504/photo/1", + "id": 1307028141697765376, + "id_str": "1307028141697765376", + "indices": [272, 295], + "media_url": "http://pbs.twimg.com/media/EiN_K4FVkAAGBcr.jpg", + "media_url_https": "https://pbs.twimg.com/media/EiN_K4FVkAAGBcr.jpg", + "sizes": { + "large": {"h": 1090, "resize": "fit", "w": 1920}, + "medium": {"h": 681, "resize": "fit", "w": 1200}, + "small": {"h": 386, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/Cey5JpR1i9", + } + ], + "symbols": [], + "urls": [ + { + "display_url": "twitch.tv/starcitizen", + "expanded_url": "http://twitch.tv/starcitizen", + "indices": [248, 271], + "url": "https://t.co/2AdNovhpFW", + } + ], + "user_mentions": [], + }, + "extended_entities": { + "media": [ + { + "display_url": "pic.twitter.com/Cey5JpR1i9", + "expanded_url": "https://twitter.com/RobertsSpaceInd/status/1307029168941461504/photo/1", + "id": 1307028141697765376, + "id_str": "1307028141697765376", + "indices": [272, 295], + "media_url": "http://pbs.twimg.com/media/EiN_K4FVkAAGBcr.jpg", + "media_url_https": "https://pbs.twimg.com/media/EiN_K4FVkAAGBcr.jpg", + "sizes": { + "large": {"h": 1090, "resize": "fit", "w": 1920}, + "medium": {"h": 681, "resize": "fit", "w": 1200}, + "small": {"h": 386, "resize": "fit", "w": 680}, + "thumb": {"h": 150, "resize": "crop", "w": 150}, + }, + "type": "photo", + "url": "https://t.co/Cey5JpR1i9", + } + ] + }, + "favorite_count": 90, + "favorited": False, + "full_text": "We\u2019re welcoming members of our Builds, Publishes and Platform teams on Star Citizen Live to talk about the process involved in bringing everyone\u2019s work together and getting it out into your hands. Going live on #Twitch in 10 minutes. \ud83c\udfa5\ud83d\udd34 \n\nTune in: https://t.co/2AdNovhpFW https://t.co/Cey5JpR1i9", + "geo": None, + "id": 1307029168941461504, + "id_str": "1307029168941461504", + "in_reply_to_screen_name": None, + "in_reply_to_status_id": None, + "in_reply_to_status_id_str": None, + "in_reply_to_user_id": None, + "in_reply_to_user_id_str": None, + "is_quote_status": False, + "lang": "en", + "place": None, + "possibly_sensitive": False, + "retweet_count": 13, + "retweeted": False, + "source": 'Twitter Web App', + "truncated": False, + "user": { + "contributors_enabled": False, + "created_at": "Wed Sep 05 00:58:11 +0000 2012", + "default_profile": False, + "default_profile_image": False, + "description": "The official Twitter profile for #StarCitizen and Roberts Space Industries.", + "entities": { + "description": {"urls": []}, + "url": { + "urls": [ + { + "display_url": "robertsspaceindustries.com", + "expanded_url": "http://www.robertsspaceindustries.com", + "indices": [0, 23], + "url": "https://t.co/iqO6apof3y", + } + ] + }, + }, + "favourites_count": 4831, + "follow_request_sent": None, + "followers_count": 106971, + "following": None, + "friends_count": 204, + "geo_enabled": False, + "has_extended_profile": False, + "id": 803542770, + "id_str": "803542770", + "is_translation_enabled": False, + "is_translator": False, + "lang": None, + "listed_count": 893, + "location": "Roberts Space Industries", + "name": "Star Citizen", + "notifications": None, + "profile_background_color": "131516", + "profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme14/bg.gif", + "profile_background_tile": False, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/803542770/1596651186", + "profile_image_url": "http://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/963109950103814144/ysnj_Asy_normal.jpg", + "profile_link_color": "0A5485", + "profile_sidebar_border_color": "FFFFFF", + "profile_sidebar_fill_color": "EFEFEF", + "profile_text_color": "333333", + "profile_use_background_image": True, + "protected": False, + "screen_name": "RobertsSpaceInd", + "statuses_count": 6368, + "time_zone": None, + "translator_type": "none", + "url": "https://t.co/iqO6apof3y", + "utc_offset": None, + "verified": True, + }, + }, +] diff --git a/src/newsreader/news/collection/tests/twitter/stream/tests.py b/src/newsreader/news/collection/tests/twitter/stream/tests.py new file mode 100644 index 0000000..4edb639 --- /dev/null +++ b/src/newsreader/news/collection/tests/twitter/stream/tests.py @@ -0,0 +1,107 @@ +from json import JSONDecodeError +from unittest.mock import patch + +from django.test import TestCase + +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamException, + StreamForbiddenException, + StreamNotFoundException, + StreamParseException, + StreamTimeOutException, + StreamTooManyException, +) +from newsreader.news.collection.tests.factories import TwitterTimelineFactory +from newsreader.news.collection.tests.twitter.stream.mocks import simple_mock +from newsreader.news.collection.twitter import TwitterStream + + +class TwitterStreamTestCase(TestCase): + def setUp(self): + self.patched_fetch = patch("newsreader.news.collection.twitter.fetch") + self.mocked_fetch = self.patched_fetch.start() + + def tearDown(self): + patch.stopall() + + def test_simple_stream(self): + self.mocked_fetch.return_value.json.return_value = simple_mock + + timeline = TwitterTimelineFactory() + stream = TwitterStream(timeline) + + data, stream = stream.read() + + self.assertEquals(data, simple_mock) + self.assertEquals(stream, stream) + + self.mocked_fetch.assert_called() + + def test_stream_raises_exception(self): + self.mocked_fetch.side_effect = StreamException + + timeline = TwitterTimelineFactory() + stream = TwitterStream(timeline) + + with self.assertRaises(StreamException): + stream.read() + + self.mocked_fetch.assert_called() + + def test_stream_raises_denied_exception(self): + self.mocked_fetch.side_effect = StreamDeniedException + + timeline = TwitterTimelineFactory() + stream = TwitterStream(timeline) + + with self.assertRaises(StreamDeniedException): + stream.read() + + self.mocked_fetch.assert_called() + + def test_stream_raises_not_found_exception(self): + self.mocked_fetch.side_effect = StreamNotFoundException + + timeline = TwitterTimelineFactory() + stream = TwitterStream(timeline) + + with self.assertRaises(StreamNotFoundException): + stream.read() + + self.mocked_fetch.assert_called() + + def test_stream_raises_time_out_exception(self): + self.mocked_fetch.side_effect = StreamTimeOutException + + timeline = TwitterTimelineFactory() + stream = TwitterStream(timeline) + + with self.assertRaises(StreamTimeOutException): + stream.read() + + self.mocked_fetch.assert_called() + + def test_stream_raises_forbidden_exception(self): + self.mocked_fetch.side_effect = StreamForbiddenException + + timeline = TwitterTimelineFactory() + stream = TwitterStream(timeline) + + with self.assertRaises(StreamForbiddenException): + stream.read() + + self.mocked_fetch.assert_called() + + def test_stream_raises_parse_exception(self): + self.mocked_fetch.return_value.json.side_effect = JSONDecodeError( + "No json found", "{}", 5 + ) + + timeline = TwitterTimelineFactory() + stream = TwitterStream(timeline) + + with self.assertRaises(StreamParseException): + stream.read() + + self.mocked_fetch.assert_called() diff --git a/src/newsreader/news/collection/tests/twitter/test_scheduler.py b/src/newsreader/news/collection/tests/twitter/test_scheduler.py new file mode 100644 index 0000000..a3c2db8 --- /dev/null +++ b/src/newsreader/news/collection/tests/twitter/test_scheduler.py @@ -0,0 +1,63 @@ +from json import JSONDecodeError +from unittest.mock import patch + +from django.test import TestCase + +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.exceptions import StreamException +from newsreader.news.collection.twitter import TwitterTimeLineScheduler + + +class TwitterTimeLineSchedulerTestCase(TestCase): + def setUp(self): + patched_fetch = patch("newsreader.news.collection.twitter.fetch") + self.mocked_fetch = patched_fetch.start() + + def test_simple(self): + user = UserFactory(twitter_oauth_token="foo", twitter_oauth_token_secret="bar") + + self.mocked_fetch.return_value.json.return_value = { + "rate_limit_context": {"application": "dummykey"}, + "resources": { + "statuses": { + "/statuses/user_timeline": { + "limit": 1500, + "remaining": 1500, + "reset": 1601141386, + } + } + }, + } + + scheduler = TwitterTimeLineScheduler(user) + + self.assertEquals(scheduler.get_current_ratelimit(), 1500) + + def test_stream_exception(self): + user = UserFactory(twitter_oauth_token=None, twitter_oauth_token_secret=None) + + self.mocked_fetch.side_effect = StreamException + + scheduler = TwitterTimeLineScheduler(user) + + self.assertEquals(scheduler.get_current_ratelimit(), None) + + def test_json_decode_error(self): + user = UserFactory(twitter_oauth_token="foo", twitter_oauth_token_secret="bar") + + self.mocked_fetch.return_value.json.side_effect = JSONDecodeError( + "foo", "bar", 10 + ) + + scheduler = TwitterTimeLineScheduler(user) + + self.assertEquals(scheduler.get_current_ratelimit(), None) + + def test_unexpected_contents(self): + user = UserFactory(twitter_oauth_token="foo", twitter_oauth_token_secret="bar") + + self.mocked_fetch.return_value.json.return_value = {"foo": "bar"} + + scheduler = TwitterTimeLineScheduler(user) + + self.assertEquals(scheduler.get_current_ratelimit(), None) diff --git a/src/newsreader/news/collection/tests/utils/tests.py b/src/newsreader/news/collection/tests/utils/tests.py index 10013c3..e88d1bf 100644 --- a/src/newsreader/news/collection/tests/utils/tests.py +++ b/src/newsreader/news/collection/tests/utils/tests.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import Mock, patch from django.test import TestCase @@ -19,7 +19,7 @@ from newsreader.news.collection.utils import fetch, post class HelperFunctionTestCase: def test_simple(self): - self.mocked_method.return_value = MagicMock(status_code=200, content="content") + self.mocked_method.return_value = Mock(status_code=200, content="content") url = "https://www.bbc.co.uk/news" response = self.method(url) @@ -27,7 +27,7 @@ class HelperFunctionTestCase: self.assertEquals(response.content, "content") def test_raises_not_found(self): - self.mocked_method.return_value = MagicMock(status_code=404) + self.mocked_method.return_value = Mock(status_code=404) url = "https://www.bbc.co.uk/news" @@ -35,7 +35,7 @@ class HelperFunctionTestCase: self.method(url) def test_raises_denied(self): - self.mocked_method.return_value = MagicMock(status_code=401) + self.mocked_method.return_value = Mock(status_code=401) url = "https://www.bbc.co.uk/news" @@ -43,7 +43,7 @@ class HelperFunctionTestCase: self.method(url) def test_raises_forbidden(self): - self.mocked_method.return_value = MagicMock(status_code=403) + self.mocked_method.return_value = Mock(status_code=403) url = "https://www.bbc.co.uk/news" @@ -51,7 +51,7 @@ class HelperFunctionTestCase: self.method(url) def test_raises_timed_out(self): - self.mocked_method.return_value = MagicMock(status_code=408) + self.mocked_method.return_value = Mock(status_code=408) url = "https://www.bbc.co.uk/news" @@ -99,7 +99,7 @@ class HelperFunctionTestCase: self.method(url) def test_raises_stream_error_on_too_many_requests(self): - self.mocked_method.return_value = MagicMock(status_code=429) + self.mocked_method.return_value = Mock(status_code=429) url = "https://www.bbc.co.uk/news" diff --git a/src/newsreader/news/collection/tests/views/base.py b/src/newsreader/news/collection/tests/views/base.py index d7de171..17f232c 100644 --- a/src/newsreader/news/collection/tests/views/base.py +++ b/src/newsreader/news/collection/tests/views/base.py @@ -49,7 +49,7 @@ class CollectionRuleViewTestCase: timezone=other_rule.timezone, ) - other_url = reverse("news:collection:rule-update", args=[other_rule.pk]) + other_url = reverse("news:collection:feed-update", args=[other_rule.pk]) response = self.client.post(other_url, self.form_data) self.assertEquals(response.status_code, 404) diff --git a/src/newsreader/news/collection/tests/views/test_crud.py b/src/newsreader/news/collection/tests/views/test_crud.py index 61f6835..7da241d 100644 --- a/src/newsreader/news/collection/tests/views/test_crud.py +++ b/src/newsreader/news/collection/tests/views/test_crud.py @@ -3,6 +3,8 @@ from django.urls import reverse import pytz +from django_celery_beat.models import PeriodicTask + from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.models import CollectionRule from newsreader.news.collection.tests.factories import FeedFactory @@ -10,11 +12,11 @@ from newsreader.news.collection.tests.views.base import CollectionRuleViewTestCa from newsreader.news.core.tests.factories import CategoryFactory -class CollectionRuleCreateViewTestCase(CollectionRuleViewTestCase, TestCase): +class FeedCreateViewTestCase(CollectionRuleViewTestCase, TestCase): def setUp(self): super().setUp() - self.url = reverse("news:collection:rule-create") + self.url = reverse("news:collection:feed-create") self.form_data.update( name="new rule", @@ -37,15 +39,21 @@ class CollectionRuleCreateViewTestCase(CollectionRuleViewTestCase, TestCase): self.assertEquals(rule.category.pk, self.category.pk) self.assertEquals(rule.user.pk, self.user.pk) + self.assertTrue( + PeriodicTask.objects.get( + name=f"{self.user.email}-feed", task="FeedTask", enabled=True + ) + ) -class CollectionRuleUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): + +class FeedUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): def setUp(self): super().setUp() self.rule = FeedFactory( name="collection rule", user=self.user, category=self.category ) - self.url = reverse("news:collection:rule-update", kwargs={"pk": self.rule.pk}) + self.url = reverse("news:collection:feed-update", kwargs={"pk": self.rule.pk}) self.form_data.update( name=self.rule.name, @@ -94,7 +102,7 @@ class CollectionRuleUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): category=self.category, type=RuleTypeChoices.subreddit, ) - url = reverse("news:collection:rule-update", kwargs={"pk": rule.pk}) + url = reverse("news:collection:feed-update", kwargs={"pk": rule.pk}) response = self.client.get(url) diff --git a/src/newsreader/news/collection/tests/views/test_import_view.py b/src/newsreader/news/collection/tests/views/test_import_view.py index f4188e7..a1f0017 100644 --- a/src/newsreader/news/collection/tests/views/test_import_view.py +++ b/src/newsreader/news/collection/tests/views/test_import_view.py @@ -84,7 +84,7 @@ class OPMLImportTestCase(TestCase): rules = CollectionRule.objects.all() self.assertEquals(len(rules), 0) - self.assertFormError(response, "form", "file", _("No (new) rules found")) + self.assertFormError(response, "form", "file", _("No (new) feeds found")) def test_invalid_feeds(self): file_path = self._get_file_path("invalid-url-feeds.opml") @@ -99,7 +99,7 @@ class OPMLImportTestCase(TestCase): rules = CollectionRule.objects.all() self.assertEquals(len(rules), 0) - self.assertFormError(response, "form", "file", _("No (new) rules found")) + self.assertFormError(response, "form", "file", _("No (new) feeds found")) def test_invalid_file(self): file_path = self._get_file_path("test.png") diff --git a/src/newsreader/news/collection/tests/views/test_twitter_views.py b/src/newsreader/news/collection/tests/views/test_twitter_views.py new file mode 100644 index 0000000..d9afa26 --- /dev/null +++ b/src/newsreader/news/collection/tests/views/test_twitter_views.py @@ -0,0 +1,129 @@ +from django.test import TestCase +from django.urls import reverse + +import pytz + +from django_celery_beat.models import PeriodicTask + +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.tests.factories import TwitterTimelineFactory +from newsreader.news.collection.tests.views.base import CollectionRuleViewTestCase +from newsreader.news.collection.twitter import TWITTER_API_URL +from newsreader.news.core.tests.factories import CategoryFactory + + +class TwitterTimelineCreateViewTestCase(CollectionRuleViewTestCase, TestCase): + def setUp(self): + super().setUp() + + self.form_data = { + "name": "new rule", + "screen_name": "RobertsSpaceInd", + "category": str(self.category.pk), + } + + self.url = reverse("news:collection:twitter-timeline-create") + + def test_creation(self): + response = self.client.post(self.url, self.form_data) + + self.assertEquals(response.status_code, 302) + + rule = CollectionRule.objects.get(name="new rule") + + self.assertEquals(rule.type, RuleTypeChoices.twitter_timeline) + self.assertEquals( + rule.url, + f"{TWITTER_API_URL}/statuses/user_timeline.json?screen_name=RobertsSpaceInd&tweet_mode=extended", + ) + self.assertEquals(rule.timezone, str(pytz.utc)) + self.assertEquals(rule.favicon, None) + self.assertEquals(rule.category.pk, self.category.pk) + self.assertEquals(rule.user.pk, self.user.pk) + + self.assertTrue( + PeriodicTask.objects.get( + name=f"{self.user.email}-timeline", + task="TwitterTimelineTask", + enabled=True, + ) + ) + + +class TwitterTimelineUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): + def setUp(self): + super().setUp() + + self.rule = TwitterTimelineFactory( + name="Star citizen", + screen_name="RobertsSpaceInd", + user=self.user, + category=self.category, + type=RuleTypeChoices.twitter_timeline, + ) + self.url = reverse( + "news:collection:twitter-timeline-update", kwargs={"pk": self.rule.pk} + ) + + self.form_data = { + "name": self.rule.name, + "screen_name": self.rule.screen_name, + "category": str(self.category.pk), + "timezone": pytz.utc, + } + + def test_name_change(self): + self.form_data.update(name="Star citizen Twitter") + + response = self.client.post(self.url, self.form_data) + self.assertEquals(response.status_code, 302) + + self.rule.refresh_from_db() + + self.assertEquals(self.rule.name, "Star citizen Twitter") + + def test_category_change(self): + new_category = CategoryFactory(user=self.user) + + self.form_data.update(category=new_category.pk) + + response = self.client.post(self.url, self.form_data) + self.assertEquals(response.status_code, 302) + + self.rule.refresh_from_db() + + self.assertEquals(self.rule.category.pk, new_category.pk) + + def test_twitter_timelines_only(self): + rule = TwitterTimelineFactory( + name="Fake twitter", + user=self.user, + category=self.category, + type=RuleTypeChoices.feed, + url="https://twitter.com/RobertsSpaceInd", + ) + url = reverse("news:collection:twitter-timeline-update", kwargs={"pk": rule.pk}) + + response = self.client.get(url) + + self.assertEquals(response.status_code, 404) + + def test_screen_name_change(self): + self.form_data.update(screen_name="CyberpunkGame") + + response = self.client.post(self.url, self.form_data) + + self.assertEquals(response.status_code, 302) + + self.rule.refresh_from_db() + + self.assertEquals(self.rule.type, RuleTypeChoices.twitter_timeline) + self.assertEquals( + self.rule.url, + f"{TWITTER_API_URL}/statuses/user_timeline.json?screen_name=CyberpunkGame&tweet_mode=extended", + ) + self.assertEquals(self.rule.timezone, str(pytz.utc)) + self.assertEquals(self.rule.favicon, None) + self.assertEquals(self.rule.category.pk, self.category.pk) + self.assertEquals(self.rule.user.pk, self.user.pk) diff --git a/src/newsreader/news/collection/twitter.py b/src/newsreader/news/collection/twitter.py new file mode 100644 index 0000000..dc32ecc --- /dev/null +++ b/src/newsreader/news/collection/twitter.py @@ -0,0 +1,281 @@ +import logging + +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime +from json import JSONDecodeError + +from django.conf import settings +from django.utils import timezone +from django.utils.html import format_html, urlize + +import pytz + +from ftfy import fix_text +from requests_oauthlib import OAuth1 as OAuth + +from newsreader.news.collection.base import ( + PostBuilder, + PostClient, + PostCollector, + PostStream, + Scheduler, +) +from newsreader.news.collection.choices import RuleTypeChoices, TwitterPostTypeChoices +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamException, + StreamNotFoundException, + StreamParseException, + StreamTimeOutException, + StreamTooManyException, +) +from newsreader.news.collection.utils import fetch, truncate_text +from newsreader.news.core.models import Post + + +logger = logging.getLogger(__name__) + +TWITTER_URL = "https://twitter.com" +TWITTER_API_URL = "https://api.twitter.com/1.1" +TWITTER_REQUEST_TOKEN_URL = "https://api.twitter.com/oauth/request_token" +TWITTER_AUTH_URL = "https://api.twitter.com/oauth/authorize" +TWITTER_ACCESS_TOKEN_URL = "https://api.twitter.com/oauth/access_token" +TWITTER_REVOKE_URL = f"{TWITTER_API_URL}/oauth/invalidate_token" + + +class TwitterBuilder(PostBuilder): + rule_type = RuleTypeChoices.twitter_timeline + + def build(self): + results = {} + rule = self.stream.rule + + for post in self.payload: + remote_identifier = post["id_str"] + + if remote_identifier in self.existing_posts: + continue + + url = f"{TWITTER_URL}/{rule.screen_name}/status/{remote_identifier}" + body = urlize(post["full_text"], nofollow=True) + title = truncate_text( + Post, "title", self.sanitize_fragment(post["full_text"]) + ) + + publication_date = pytz.utc.localize( + datetime.strptime(post["created_at"], "%a %b %d %H:%M:%S +0000 %Y") + ) + + if "extended_entities" in post: + try: + media_entities = self.get_media_entities(post) + body += media_entities + except KeyError: + logger.exception(f"Failed parsing media_entities for {url}") + + if "retweeted_status" in post: + original_post = post["retweeted_status"] + original_tweet = urlize(original_post["full_text"], nofollow=True) + body = f"{body}
      Original tweet: {original_tweet}
      " + if "quoted_status" in post: + original_post = post["quoted_status"] + original_tweet = urlize(original_post["full_text"], nofollow=True) + body = f"{body}
      Quoted tweet: {original_tweet}
      " + + body = self.sanitize_fragment(body) + + data = { + "remote_identifier": remote_identifier, + "title": fix_text(title), + "body": fix_text(body), + "author": rule.screen_name, + "publication_date": publication_date, + "url": url, + "rule": rule, + } + + results[remote_identifier] = Post(**data) + + self.instances = results.values() + + def get_media_entities(self, post): + media_entities = post["extended_entities"]["media"] + formatted_entities = "" + + for media_entity in media_entities: + media_type = media_entity["type"] + media_url = media_entity["media_url_https"] + title = media_entity["id_str"] + + if media_type == TwitterPostTypeChoices.photo: + html_fragment = format_html( + """
      {title}
      """, + title=title, + media_url=media_url, + ) + + formatted_entities += html_fragment + + elif media_type in ( + TwitterPostTypeChoices.video, + TwitterPostTypeChoices.animated_gif, + ): + meta_data = media_entity["video_info"] + + videos = sorted( + [video for video in meta_data["variants"]], + reverse=True, + key=lambda video: video.get("bitrate", 0), + ) + + if not videos: + continue + + video = videos[0] + content_type = video["content_type"] + url = video["url"] + + html_fragment = format_html( + """
      """, + url=url, + content_type=content_type, + ) + + formatted_entities += html_fragment + + return formatted_entities + + +class TwitterStream(PostStream): + rule_type = RuleTypeChoices.twitter_timeline + + def read(self): + oauth = OAuth( + settings.TWITTER_CONSUMER_ID, + client_secret=settings.TWITTER_CONSUMER_SECRET, + resource_owner_key=self.rule.user.twitter_oauth_token, + resource_owner_secret=self.rule.user.twitter_oauth_token_secret, + ) + + response = fetch(self.rule.url, auth=oauth) + + return self.parse(response), self + + def parse(self, response): + try: + return response.json() + except JSONDecodeError as e: + raise StreamParseException( + response=response, message="Failed parsing json" + ) from e + + +class TwitterClient(PostClient): + stream = TwitterStream + + def __enter__(self): + streams = [self.stream(timeline) for timeline in self.rules] + + with ThreadPoolExecutor(max_workers=10) as executor: + futures = {executor.submit(stream.read): stream for stream in streams} + + for future in as_completed(futures): + stream = futures[future] + + try: + payload = future.result() + + stream.rule.error = None + stream.rule.succeeded = True + + yield payload + except StreamTooManyException as e: + logger.exception("Ratelimit hit, aborting twitter calls") + + self.set_rule_error(stream.rule, e) + + break + except StreamDeniedException as e: + logger.warning( + f"Access token expired for user {stream.rule.user.pk}" + ) + + stream.rule.user.twitter_oauth_token = None + stream.rule.user.twitter_oauth_token_secret = None + stream.rule.user.save() + + self.set_rule_error(stream.rule, e) + + break + except (StreamNotFoundException, StreamTimeOutException) as e: + logger.warning(f"Request failed for {stream.rule.screen_name}") + + self.set_rule_error(stream.rule, e) + + continue + except StreamException as e: + logger.exception(f"Request failed for {stream.rule.screen_name}") + + self.set_rule_error(stream.rule, e) + + continue + finally: + stream.rule.last_run = timezone.now() + stream.rule.save() + + +class TwitterCollector(PostCollector): + builder = TwitterBuilder + client = TwitterClient + + +# see https://developer.twitter.com/en/docs/twitter-api/v1/rate-limits +class TwitterTimeLineScheduler(Scheduler): + def __init__(self, user, timelines=[]): + self.user = user + + if not timelines: + self.timelines = ( + user.rules.enabled() + .filter(type=RuleTypeChoices.twitter_timeline) + .order_by("last_run")[:200] + ) + else: + self.timelines = timelines + + def get_scheduled_rules(self): + max_amount = self.get_current_ratelimit() + return self.timelines[:max_amount] if max_amount else [] + + def get_current_ratelimit(self): + endpoint = "application/rate_limit_status.json?resources=statuses" + + if ( + not self.user.twitter_oauth_token + or not self.user.twitter_oauth_token_secret + ): + return + + oauth = OAuth( + settings.TWITTER_CONSUMER_ID, + client_secret=settings.TWITTER_CONSUMER_SECRET, + resource_owner_key=self.user.twitter_oauth_token, + resource_owner_secret=self.user.twitter_oauth_token_secret, + ) + + try: + response = fetch(f"{TWITTER_API_URL}/{endpoint}", auth=oauth) + except StreamException: + logger.exception(f"Unable to retrieve current ratelimit for {self.user.pk}") + return + + try: + payload = response.json() + except JSONDecodeError: + logger.exception(f"Unable to parse ratelimit request for {self.user.pk}") + return + + try: + return payload["resources"]["statuses"]["/statuses/user_timeline"]["limit"] + except KeyError: + return diff --git a/src/newsreader/news/collection/urls.py b/src/newsreader/news/collection/urls.py index 5253210..7d883f2 100644 --- a/src/newsreader/news/collection/urls.py +++ b/src/newsreader/news/collection/urls.py @@ -11,12 +11,14 @@ from newsreader.news.collection.views import ( CollectionRuleBulkDeleteView, CollectionRuleBulkDisableView, CollectionRuleBulkEnableView, - CollectionRuleCreateView, CollectionRuleListView, - CollectionRuleUpdateView, + FeedCreateView, + FeedUpdateView, OPMLImportView, SubRedditCreateView, SubRedditUpdateView, + TwitterTimelineCreateView, + TwitterTimelineUpdateView, ) @@ -28,17 +30,13 @@ endpoints = [ ] urlpatterns = [ + # Feeds + path( + "feeds//", login_required(FeedUpdateView.as_view()), name="feed-update" + ), + path("feeds/create/", login_required(FeedCreateView.as_view()), name="feed-create"), + # Generic rules path("rules/", login_required(CollectionRuleListView.as_view()), name="rules"), - path( - "rules//", - login_required(CollectionRuleUpdateView.as_view()), - name="rule-update", - ), - path( - "rules/create/", - login_required(CollectionRuleCreateView.as_view()), - name="rule-create", - ), path( "rules/delete/", login_required(CollectionRuleBulkDeleteView.as_view()), @@ -54,15 +52,27 @@ urlpatterns = [ login_required(CollectionRuleBulkDisableView.as_view()), name="rules-disable", ), + path("rules/import/", login_required(OPMLImportView.as_view()), name="import"), + # Reddit path( - "rules/subreddits/create/", + "subreddits/create/", login_required(SubRedditCreateView.as_view()), name="subreddit-create", ), path( - "rules/subreddits//", + "subreddits//", login_required(SubRedditUpdateView.as_view()), name="subreddit-update", ), - path("rules/import/", login_required(OPMLImportView.as_view()), name="import"), + # Twitter + path( + "twitter/timelines/create/", + login_required(TwitterTimelineCreateView.as_view()), + name="twitter-timeline-create", + ), + path( + "twitter/timelines//", + login_required(TwitterTimelineUpdateView.as_view()), + name="twitter-timeline-update", + ), ] diff --git a/src/newsreader/news/collection/utils.py b/src/newsreader/news/collection/utils.py index 4cfc0e7..0eb1dc0 100644 --- a/src/newsreader/news/collection/utils.py +++ b/src/newsreader/news/collection/utils.py @@ -25,12 +25,12 @@ def build_publication_date(dt, tz): return published_parsed.astimezone(pytz.utc) -def fetch(url, headers={}): +def fetch(url, auth=None, headers={}): headers = {**DEFAULT_HEADERS, **headers} with ResponseHandler() as response_handler: try: - response = requests.get(url, headers=headers) + response = requests.get(url, auth=auth, headers=headers) response_handler.handle_response(response) except RequestException as exception: response_handler.map_exception(exception) diff --git a/src/newsreader/news/collection/views/__init__.py b/src/newsreader/news/collection/views/__init__.py index 20769f3..c66c5a5 100644 --- a/src/newsreader/news/collection/views/__init__.py +++ b/src/newsreader/news/collection/views/__init__.py @@ -1,3 +1,8 @@ +from newsreader.news.collection.views.feed import ( + FeedCreateView, + FeedUpdateView, + OPMLImportView, +) from newsreader.news.collection.views.reddit import ( SubRedditCreateView, SubRedditUpdateView, @@ -6,8 +11,9 @@ from newsreader.news.collection.views.rules import ( CollectionRuleBulkDeleteView, CollectionRuleBulkDisableView, CollectionRuleBulkEnableView, - CollectionRuleCreateView, CollectionRuleListView, - CollectionRuleUpdateView, - OPMLImportView, +) +from newsreader.news.collection.views.twitter import ( + TwitterTimelineCreateView, + TwitterTimelineUpdateView, ) diff --git a/src/newsreader/news/collection/views/base.py b/src/newsreader/news/collection/views/base.py index e7f7b63..d7a3a4d 100644 --- a/src/newsreader/news/collection/views/base.py +++ b/src/newsreader/news/collection/views/base.py @@ -1,8 +1,11 @@ +import json + from django.urls import reverse_lazy import pytz -from newsreader.news.collection.forms import CollectionRuleForm +from django_celery_beat.models import IntervalSchedule, PeriodicTask + from newsreader.news.collection.models import CollectionRule from newsreader.news.core.models import Category @@ -17,7 +20,6 @@ class CollectionRuleViewMixin: class CollectionRuleDetailMixin: success_url = reverse_lazy("news:collection:rules") - form_class = CollectionRuleForm def get_context_data(self, **kwargs): context_data = super().get_context_data(**kwargs) @@ -34,3 +36,25 @@ class CollectionRuleDetailMixin: kwargs = super().get_form_kwargs() kwargs["user"] = self.request.user return kwargs + + +class TaskCreationMixin: + def form_valid(self, form): + response = super().form_valid(form) + + interval, period = self.task_interval + task_interval, _ = IntervalSchedule.objects.get_or_create( + every=interval, period=period + ) + + PeriodicTask.objects.get_or_create( + name=f"{self.request.user.email}-{self.task_name}", + task=self.task_type, + defaults={ + "args": json.dumps([self.request.user.pk]), + "interval": task_interval, + "enabled": True, + }, + ) + + return response diff --git a/src/newsreader/news/collection/views/feed.py b/src/newsreader/news/collection/views/feed.py new file mode 100644 index 0000000..b7803d2 --- /dev/null +++ b/src/newsreader/news/collection/views/feed.py @@ -0,0 +1,70 @@ +from django.contrib import messages +from django.urls import reverse +from django.utils.translation import gettext as _ +from django.views.generic.edit import CreateView, FormView, UpdateView + +from django_celery_beat.models import IntervalSchedule + +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.forms import ( + CollectionRuleBulkForm, + FeedForm, + OPMLImportForm, +) +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.views.base import ( + CollectionRuleDetailMixin, + CollectionRuleViewMixin, + TaskCreationMixin, +) +from newsreader.utils.opml import parse_opml + + +class FeedUpdateView(CollectionRuleViewMixin, CollectionRuleDetailMixin, UpdateView): + template_name = "news/collection/views/feed-update.html" + context_object_name = "feed" + form_class = FeedForm + + def get_queryset(self): + queryset = super().get_queryset() + return queryset.filter(type=RuleTypeChoices.feed) + + +class FeedCreateView( + CollectionRuleViewMixin, CollectionRuleDetailMixin, TaskCreationMixin, CreateView +): + template_name = "news/collection/views/feed-create.html" + task_interval = (1, IntervalSchedule.HOURS) + task_name = "feed" + task_type = "FeedTask" + form_class = FeedForm + + +class OPMLImportView(FormView): + form_class = OPMLImportForm + template_name = "news/collection/views/import.html" + + def form_valid(self, form): + user = self.request.user + file = form.cleaned_data["file"] + skip_existing = form.cleaned_data["skip_existing"] + + instances = parse_opml(file, user, skip_existing=skip_existing) + + try: + feeds = CollectionRule.objects.bulk_create(instances) + except IOError: + form.add_error("file", _("Invalid OPML file")) + return self.form_invalid(form) + + if not feeds: + form.add_error("file", _("No (new) feeds found")) + return self.form_invalid(form) + + message = _(f"{len(feeds)} new feeds created") + messages.success(self.request, message) + + return super().form_valid(form) + + def get_success_url(self): + return reverse("news:collection:rules") diff --git a/src/newsreader/news/collection/views/reddit.py b/src/newsreader/news/collection/views/reddit.py index 62ec408..4e44e3f 100644 --- a/src/newsreader/news/collection/views/reddit.py +++ b/src/newsreader/news/collection/views/reddit.py @@ -1,7 +1,7 @@ from django.views.generic.edit import CreateView, UpdateView from newsreader.news.collection.choices import RuleTypeChoices -from newsreader.news.collection.forms import SubRedditRuleForm +from newsreader.news.collection.forms import SubRedditForm from newsreader.news.collection.views.base import ( CollectionRuleDetailMixin, CollectionRuleViewMixin, @@ -11,14 +11,14 @@ from newsreader.news.collection.views.base import ( class SubRedditCreateView( CollectionRuleViewMixin, CollectionRuleDetailMixin, CreateView ): - form_class = SubRedditRuleForm + form_class = SubRedditForm template_name = "news/collection/views/subreddit-create.html" class SubRedditUpdateView( CollectionRuleViewMixin, CollectionRuleDetailMixin, UpdateView ): - form_class = SubRedditRuleForm + form_class = SubRedditForm template_name = "news/collection/views/subreddit-update.html" context_object_name = "subreddit" diff --git a/src/newsreader/news/collection/views/rules.py b/src/newsreader/news/collection/views/rules.py index e020b67..902eedf 100644 --- a/src/newsreader/news/collection/views/rules.py +++ b/src/newsreader/news/collection/views/rules.py @@ -2,17 +2,14 @@ from django.contrib import messages from django.shortcuts import redirect from django.urls import reverse from django.utils.translation import gettext as _ -from django.views.generic.edit import CreateView, FormView, UpdateView +from django.views.generic.edit import FormView from django.views.generic.list import ListView -from newsreader.news.collection.choices import RuleTypeChoices -from newsreader.news.collection.forms import CollectionRuleBulkForm, OPMLImportForm -from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.forms import CollectionRuleBulkForm from newsreader.news.collection.views.base import ( CollectionRuleDetailMixin, CollectionRuleViewMixin, ) -from newsreader.utils.opml import parse_opml class CollectionRuleListView(CollectionRuleViewMixin, ListView): @@ -21,23 +18,6 @@ class CollectionRuleListView(CollectionRuleViewMixin, ListView): context_object_name = "rules" -class CollectionRuleUpdateView( - CollectionRuleViewMixin, CollectionRuleDetailMixin, UpdateView -): - template_name = "news/collection/views/rule-update.html" - context_object_name = "rule" - - def get_queryset(self): - queryset = super().get_queryset() - return queryset.filter(type=RuleTypeChoices.feed) - - -class CollectionRuleCreateView( - CollectionRuleViewMixin, CollectionRuleDetailMixin, CreateView -): - template_name = "news/collection/views/rule-create.html" - - class CollectionRuleBulkView(FormView): form_class = CollectionRuleBulkForm @@ -90,33 +70,3 @@ class CollectionRuleBulkDeleteView(CollectionRuleBulkView): rule.delete() return response - - -class OPMLImportView(FormView): - form_class = OPMLImportForm - template_name = "news/collection/views/import.html" - - def form_valid(self, form): - user = self.request.user - file = form.cleaned_data["file"] - skip_existing = form.cleaned_data["skip_existing"] - - instances = parse_opml(file, user, skip_existing=skip_existing) - - try: - rules = CollectionRule.objects.bulk_create(instances) - except IOError: - form.add_error("file", _("Invalid OPML file")) - return self.form_invalid(form) - - if not rules: - form.add_error("file", _("No (new) rules found")) - return self.form_invalid(form) - - message = _(f"{len(rules)} new rules created") - messages.success(self.request, message) - - return super().form_valid(form) - - def get_success_url(self): - return reverse("news:collection:rules") diff --git a/src/newsreader/news/collection/views/twitter.py b/src/newsreader/news/collection/views/twitter.py new file mode 100644 index 0000000..0221a75 --- /dev/null +++ b/src/newsreader/news/collection/views/twitter.py @@ -0,0 +1,33 @@ +from django.views.generic.edit import CreateView, UpdateView + +from django_celery_beat.models import IntervalSchedule + +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.forms import TwitterTimelineForm +from newsreader.news.collection.views.base import ( + CollectionRuleDetailMixin, + CollectionRuleViewMixin, + TaskCreationMixin, +) + + +class TwitterTimelineCreateView( + CollectionRuleViewMixin, CollectionRuleDetailMixin, TaskCreationMixin, CreateView +): + form_class = TwitterTimelineForm + template_name = "news/collection/views/twitter/timeline-create.html" + task_interval = (10, IntervalSchedule.MINUTES) + task_name = "timeline" + task_type = "TwitterTimelineTask" + + +class TwitterTimelineUpdateView( + CollectionRuleViewMixin, CollectionRuleDetailMixin, UpdateView +): + form_class = TwitterTimelineForm + template_name = "news/collection/views/twitter/timeline-update.html" + context_object_name = "timeline" + + def get_queryset(self): + queryset = super().get_queryset() + return queryset.filter(type=RuleTypeChoices.twitter_timeline) diff --git a/src/newsreader/news/core/templates/news/core/views/categories.html b/src/newsreader/news/core/templates/news/core/views/categories.html index 35fc741..6a6cdae 100644 --- a/src/newsreader/news/core/templates/news/core/views/categories.html +++ b/src/newsreader/news/core/templates/news/core/views/categories.html @@ -30,5 +30,8 @@ ] + {{ categories_update_url|json_script:"updateUrl" }} + {{ categories_create_url|json_script:"createUrl" }} + {{ block.super }} {% endblock %} diff --git a/src/newsreader/news/core/templates/news/core/views/homepage.html b/src/newsreader/news/core/templates/news/core/views/homepage.html index 79e1ccc..502ef63 100644 --- a/src/newsreader/news/core/templates/news/core/views/homepage.html +++ b/src/newsreader/news/core/templates/news/core/views/homepage.html @@ -3,4 +3,13 @@ {% block content %}
      -{% endblock %} +{% endblock content %} + +{% block scripts %} + {{ feed_url|json_script:"feedUrl" }} + {{ subreddit_url|json_script:"subredditUrl" }} + {{ twitter_timeline_url|json_script:"timelineUrl" }} + {{ categories_url|json_script:"categoriesUrl" }} + + {{ block.super }} +{% endblock scripts %} diff --git a/src/newsreader/news/core/views.py b/src/newsreader/news/core/views.py index 9ef81eb..981e7b2 100644 --- a/src/newsreader/news/core/views.py +++ b/src/newsreader/news/core/views.py @@ -11,24 +11,21 @@ from newsreader.news.core.models import Category class NewsView(TemplateView): template_name = "news/core/views/homepage.html" - # TODO serialize objects to show filled main page def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - user = self.request.user - categories = { - category: category.rules.order_by("-created") - for category in user.categories.order_by("name") + return { + **context, + "feed_url": reverse_lazy("news:collection:feed-update", args=(0,)), + "subreddit_url": reverse_lazy( + "news:collection:subreddit-update", args=(0,) + ), + "twitter_timeline_url": reverse_lazy( + "news:collection:twitter-timeline-update", args=(0,) + ), + "categories_url": reverse_lazy("news:core:category-update", args=(0,)), } - rules = { - rule: rule.posts.order_by("-publication_date")[:30] - for rule in user.rules.order_by("-created") - } - - context.update(categories=categories, rules=rules) - return context - class CategoryViewMixin: queryset = Category.objects.prefetch_related("rules").order_by("name") @@ -58,6 +55,17 @@ class CategoryListView(CategoryViewMixin, ListView): template_name = "news/core/views/categories.html" context_object_name = "categories" + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + + return { + **context, + "categories_create_url": reverse_lazy("news:core:category-create"), + "categories_update_url": ( + reverse_lazy("news:core:category-update", args=(0,)) + ), + } + class CategoryUpdateView(CategoryViewMixin, CategoryDetailMixin, UpdateView): template_name = "news/core/views/category-update.html" diff --git a/src/newsreader/scss/components/header/_header.scss b/src/newsreader/scss/components/header/_header.scss new file mode 100644 index 0000000..ed96dc6 --- /dev/null +++ b/src/newsreader/scss/components/header/_header.scss @@ -0,0 +1,3 @@ +.header { + padding: 15px; +} diff --git a/src/newsreader/scss/components/header/index.scss b/src/newsreader/scss/components/header/index.scss new file mode 100644 index 0000000..5c23e3e --- /dev/null +++ b/src/newsreader/scss/components/header/index.scss @@ -0,0 +1 @@ +@import './header'; diff --git a/src/newsreader/scss/components/index.scss b/src/newsreader/scss/components/index.scss index cc9e717..b82a22d 100644 --- a/src/newsreader/scss/components/index.scss +++ b/src/newsreader/scss/components/index.scss @@ -8,6 +8,7 @@ @import './card/index'; @import './list/index'; +@import './header/index'; @import './messages/index'; @import './section/index'; @import './errorlist/index'; @@ -16,6 +17,8 @@ @import './sidebar/index'; @import './table/index'; +@import './integrations/index'; + @import './rules/index'; @import './category/index'; diff --git a/src/newsreader/scss/components/integrations/_integrations.scss b/src/newsreader/scss/components/integrations/_integrations.scss new file mode 100644 index 0000000..815184e --- /dev/null +++ b/src/newsreader/scss/components/integrations/_integrations.scss @@ -0,0 +1,12 @@ +.integrations { + display: flex; + flex-direction: column; + gap: 15px; + + padding: 15px; + + &__controls { + display: flex; + gap: 10px; + } +} diff --git a/src/newsreader/scss/components/integrations/index.scss b/src/newsreader/scss/components/integrations/index.scss new file mode 100644 index 0000000..7f9e759 --- /dev/null +++ b/src/newsreader/scss/components/integrations/index.scss @@ -0,0 +1 @@ +@import './integrations'; diff --git a/src/newsreader/scss/elements/button/_button.scss b/src/newsreader/scss/elements/button/_button.scss index a8eb3bc..7cd062a 100644 --- a/src/newsreader/scss/elements/button/_button.scss +++ b/src/newsreader/scss/elements/button/_button.scss @@ -44,10 +44,24 @@ &--reddit { color: $white !important; - background-color: lighten($reddit-orange, 5%); + background-color: $reddit-orange; &:hover { - background-color: $reddit-orange; + background-color: lighten($reddit-orange, 5%); } } + + &--twitter { + color: $white !important; + background-color: $twitter-blue; + + &:hover { + background-color: lighten($twitter-blue, 5%); + } + } + + &--disabled { + color: $font-color !important; + background-color: $gray !important; + } } diff --git a/src/newsreader/scss/pages/index.scss b/src/newsreader/scss/pages/index.scss index 44ca8a7..2ac0bb2 100644 --- a/src/newsreader/scss/pages/index.scss +++ b/src/newsreader/scss/pages/index.scss @@ -12,3 +12,4 @@ @import './rules/index'; @import './settings/index'; +@import './integrations/index'; diff --git a/src/newsreader/scss/pages/integrations/index.scss b/src/newsreader/scss/pages/integrations/index.scss new file mode 100644 index 0000000..ccf52c3 --- /dev/null +++ b/src/newsreader/scss/pages/integrations/index.scss @@ -0,0 +1,5 @@ +#integrations--page { + .section { + width: 70%; + } +} diff --git a/src/newsreader/scss/partials/_colors.scss b/src/newsreader/scss/partials/_colors.scss index b2f124d..87f6e49 100644 --- a/src/newsreader/scss/partials/_colors.scss +++ b/src/newsreader/scss/partials/_colors.scss @@ -12,6 +12,7 @@ $font-color: rgba(48, 51, 53, 1); $header-color: rgba(100, 101, 102, 1); $reddit-orange: rgba(255, 69, 0, 1); +$twitter-blue: rgba(29, 155, 240, 1); $transparant-red: transparentize($red, 0.8); $transparant-blue: transparentize($blue, 0.8); diff --git a/src/newsreader/templates/components/form/form.html b/src/newsreader/templates/components/form/form.html index e183c25..9f1ab47 100644 --- a/src/newsreader/templates/components/form/form.html +++ b/src/newsreader/templates/components/form/form.html @@ -4,7 +4,7 @@ {% csrf_token %} {% if title %} - {% include "components/form/title.html" with title=title only %} + {% include "components/header/header.html" with title=title only %} {% endif %} {% block intro %} diff --git a/src/newsreader/templates/components/form/title.html b/src/newsreader/templates/components/form/title.html deleted file mode 100644 index 3adcb75..0000000 --- a/src/newsreader/templates/components/form/title.html +++ /dev/null @@ -1,3 +0,0 @@ -
      -

      {{ title }}

      -
      diff --git a/src/newsreader/templates/components/header/header.html b/src/newsreader/templates/components/header/header.html new file mode 100644 index 0000000..c21c233 --- /dev/null +++ b/src/newsreader/templates/components/header/header.html @@ -0,0 +1,3 @@ +
      +

      {{ title }}

      +
      diff --git a/src/newsreader/utils/opml.py b/src/newsreader/utils/opml.py index 55a9387..1aca0fd 100644 --- a/src/newsreader/utils/opml.py +++ b/src/newsreader/utils/opml.py @@ -38,4 +38,5 @@ def parse_opml(file, user, skip_existing=False): logging.info(f"Skipped due to invalid URL: {e}") continue + # TODO create feed type rules yield CollectionRule(url=feed_url, name=name, user=user) diff --git a/webpack.common.babel.js b/webpack.common.babel.js index 4ad1700..bbfb403 100644 --- a/webpack.common.babel.js +++ b/webpack.common.babel.js @@ -26,8 +26,9 @@ export default { use: { loader: 'file-loader', options: { - name: 'fonts/[name].[ext]', - publicPath: '../', + name: '[name].[ext]', + outputPath: 'fonts', + publicPath: '/static/fonts/', }, }, }, From ca5c2f6b55eb1718d9f4d10783dfb69e9de9fe9c Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Sun, 27 Sep 2020 16:45:28 +0200 Subject: [PATCH 178/422] 0.3.1 Use ansible repo's master branch for deployments --- gitlab-ci/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitlab-ci/deploy.yml b/gitlab-ci/deploy.yml index 1d0df56..49b4bd3 100644 --- a/gitlab-ci/deploy.yml +++ b/gitlab-ci/deploy.yml @@ -8,7 +8,7 @@ deploy: - if: $CI_COMMIT_TAG before_script: - pip install ansible --quiet - - git clone https://git.fudiggity.nl/sonny/ansible-playbooks.git deployment + - git clone https://git.fudiggity.nl/sonny/ansible-playbooks.git deployment --branch master - mkdir /root/.ssh && echo "$DEPLOY_HOST_KEY" > /root/.ssh/known_hosts - echo "$DEPLOY_KEY" > deployment/deploy_key && chmod 0600 deployment/deploy_key - echo "$VAULT_PASSWORD" > deployment/vault From 48388a47f6d4a596afbb2281a78efddcc879304e Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Tue, 6 Oct 2020 22:06:19 +0200 Subject: [PATCH 179/422] Add user runnable favicon task --- .../accounts/components/settings-form.html | 17 +++- .../accounts/views/password-change.html | 2 +- .../templates/accounts/views/reddit.html | 2 +- .../templates/accounts/views/twitter.html | 2 +- src/newsreader/accounts/tests/test_favicon.py | 37 +++++++++ .../accounts/tests/test_integrations.py | 62 +++++++------- .../accounts/tests/test_settings.py | 4 +- src/newsreader/accounts/urls.py | 81 ++++++++++--------- src/newsreader/accounts/views/__init__.py | 1 + src/newsreader/accounts/views/favicon.py | 26 ++++++ src/newsreader/accounts/views/integrations.py | 16 ++-- src/newsreader/accounts/views/settings.py | 9 +++ src/newsreader/news/collection/favicon.py | 2 +- .../collection/management/commands/collect.py | 11 --- .../management/commands/fetch_favicons.py | 11 --- src/newsreader/news/collection/tasks.py | 42 ++++++++++ src/newsreader/templates/base.html | 2 +- 17 files changed, 218 insertions(+), 109 deletions(-) create mode 100644 src/newsreader/accounts/tests/test_favicon.py create mode 100644 src/newsreader/accounts/views/favicon.py delete mode 100644 src/newsreader/news/collection/management/commands/collect.py delete mode 100644 src/newsreader/news/collection/management/commands/fetch_favicons.py diff --git a/src/newsreader/accounts/templates/accounts/components/settings-form.html b/src/newsreader/accounts/templates/accounts/components/settings-form.html index 51d4450..f5e7065 100644 --- a/src/newsreader/accounts/templates/accounts/components/settings-form.html +++ b/src/newsreader/accounts/templates/accounts/components/settings-form.html @@ -4,14 +4,25 @@ {% block actions %}
      + {% include "components/form/confirm-button.html" %} + {% trans "Change password" %} - + + {% if favicon_task_allowed %} + + {% trans "Fetch favicons" %} + + {% else %} + + {% endif %} + + {% trans "Third party integrations" %} - - {% include "components/form/confirm-button.html" %}
      {% endblock actions %} diff --git a/src/newsreader/accounts/templates/accounts/views/password-change.html b/src/newsreader/accounts/templates/accounts/views/password-change.html index fb8a98b..d6eb918 100644 --- a/src/newsreader/accounts/templates/accounts/views/password-change.html +++ b/src/newsreader/accounts/templates/accounts/views/password-change.html @@ -2,7 +2,7 @@ {% block content %}
      - {% url 'accounts:settings' as cancel_url %} + {% url 'accounts:settings:home' as cancel_url %} {% include "components/form/form.html" with form=form title="Change password" confirm_text="Change password" cancel_url=cancel_url %}
      {% endblock %} diff --git a/src/newsreader/accounts/templates/accounts/views/reddit.html b/src/newsreader/accounts/templates/accounts/views/reddit.html index 5d4f539..353ca72 100644 --- a/src/newsreader/accounts/templates/accounts/views/reddit.html +++ b/src/newsreader/accounts/templates/accounts/views/reddit.html @@ -13,7 +13,7 @@ {% endif %}

      - {% trans "Return to integrations page" %} + {% trans "Return to integrations page" %}

      diff --git a/src/newsreader/accounts/templates/accounts/views/twitter.html b/src/newsreader/accounts/templates/accounts/views/twitter.html index e2c51aa..6df1a97 100644 --- a/src/newsreader/accounts/templates/accounts/views/twitter.html +++ b/src/newsreader/accounts/templates/accounts/views/twitter.html @@ -13,7 +13,7 @@ {% endif %}

      - {% trans "Return to integrations page" %} + {% trans "Return to integrations page" %}

      diff --git a/src/newsreader/accounts/tests/test_favicon.py b/src/newsreader/accounts/tests/test_favicon.py new file mode 100644 index 0000000..d3eb56b --- /dev/null +++ b/src/newsreader/accounts/tests/test_favicon.py @@ -0,0 +1,37 @@ +from unittest.mock import patch + +from django.core.cache import cache +from django.test import TestCase +from django.urls import reverse + +from newsreader.accounts.tests.factories import UserFactory + + +class FaviconRedirectViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(email="test@test.nl", password="test") + self.client.force_login(self.user) + + self.patch = patch("newsreader.accounts.views.favicon.FaviconTask") + self.mocked_task = self.patch.start() + + def tearDown(self): + cache.clear() + + def test_simple(self): + response = self.client.get(reverse("accounts:settings:favicon")) + + self.assertRedirects(response, reverse("accounts:settings:home")) + + self.mocked_task.delay.assert_called_once_with(self.user.pk) + + self.assertEqual(1, cache.get(f"{self.user.email}-favicon-task")) + + def test_not_active(self): + cache.set(f"{self.user.email}-favicon-task", 1) + + response = self.client.get(reverse("accounts:settings:favicon")) + + self.assertRedirects(response, reverse("accounts:settings:home")) + + self.mocked_task.delay.assert_not_called() diff --git a/src/newsreader/accounts/tests/test_integrations.py b/src/newsreader/accounts/tests/test_integrations.py index cdc9546..fbee223 100644 --- a/src/newsreader/accounts/tests/test_integrations.py +++ b/src/newsreader/accounts/tests/test_integrations.py @@ -22,7 +22,7 @@ class IntegrationsViewTestCase(TestCase): self.user = UserFactory(email="test@test.nl", password="test") self.client.force_login(self.user) - self.url = reverse("accounts:integrations") + self.url = reverse("accounts:settings:integrations") class RedditIntegrationsTestCase(IntegrationsViewTestCase): @@ -69,7 +69,7 @@ class RedditTemplateViewTestCase(TestCase): self.user = UserFactory(email="test@test.nl", password="test") self.client.force_login(self.user) - self.base_url = reverse("accounts:reddit-template") + self.base_url = reverse("accounts:settings:reddit-template") self.state = str(uuid4()) self.patch = patch("newsreader.news.collection.reddit.post") @@ -190,9 +190,9 @@ class RedditTokenRedirectViewTestCase(TestCase): cache.clear() def test_simple(self): - response = self.client.get(reverse("accounts:reddit-refresh")) + response = self.client.get(reverse("accounts:settings:reddit-refresh")) - self.assertRedirects(response, reverse("accounts:integrations")) + self.assertRedirects(response, reverse("accounts:settings:integrations")) self.mocked_task.delay.assert_called_once_with(self.user.pk) @@ -201,9 +201,9 @@ class RedditTokenRedirectViewTestCase(TestCase): def test_not_active(self): cache.set(f"{self.user.email}-reddit-refresh", 1) - response = self.client.get(reverse("accounts:reddit-refresh")) + response = self.client.get(reverse("accounts:settings:reddit-refresh")) - self.assertRedirects(response, reverse("accounts:integrations")) + self.assertRedirects(response, reverse("accounts:settings:integrations")) self.mocked_task.delay.assert_not_called() @@ -223,9 +223,9 @@ class RedditRevokeRedirectViewTestCase(TestCase): self.mocked_revoke.return_value = True - response = self.client.get(reverse("accounts:reddit-revoke")) + response = self.client.get(reverse("accounts:settings:reddit-revoke")) - self.assertRedirects(response, reverse("accounts:integrations")) + self.assertRedirects(response, reverse("accounts:settings:integrations")) self.mocked_revoke.assert_called_once_with(self.user) @@ -238,9 +238,9 @@ class RedditRevokeRedirectViewTestCase(TestCase): self.user.reddit_refresh_token = None self.user.save() - response = self.client.get(reverse("accounts:reddit-revoke")) + response = self.client.get(reverse("accounts:settings:reddit-revoke")) - self.assertRedirects(response, reverse("accounts:integrations")) + self.assertRedirects(response, reverse("accounts:settings:integrations")) self.mocked_revoke.assert_not_called() @@ -251,9 +251,9 @@ class RedditRevokeRedirectViewTestCase(TestCase): self.mocked_revoke.return_value = False - response = self.client.get(reverse("accounts:reddit-revoke")) + response = self.client.get(reverse("accounts:settings:reddit-revoke")) - self.assertRedirects(response, reverse("accounts:integrations")) + self.assertRedirects(response, reverse("accounts:settings:integrations")) self.user.refresh_from_db() @@ -267,9 +267,9 @@ class RedditRevokeRedirectViewTestCase(TestCase): self.mocked_revoke.side_effect = StreamException - response = self.client.get(reverse("accounts:reddit-revoke")) + response = self.client.get(reverse("accounts:settings:reddit-revoke")) - self.assertRedirects(response, reverse("accounts:integrations")) + self.assertRedirects(response, reverse("accounts:settings:integrations")) self.user.refresh_from_db() @@ -293,9 +293,9 @@ class TwitterRevokeRedirectView(TestCase): self.user.twitter_oauth_token_secret = "jadajadajada" self.user.save() - response = self.client.get(reverse("accounts:twitter-revoke")) + response = self.client.get(reverse("accounts:settings:twitter-revoke")) - self.assertRedirects(response, reverse("accounts:integrations")) + self.assertRedirects(response, reverse("accounts:settings:integrations")) self.user.refresh_from_db() @@ -307,9 +307,9 @@ class TwitterRevokeRedirectView(TestCase): self.user.twitter_oauth_token_secret = None self.user.save() - response = self.client.get(reverse("accounts:twitter-revoke")) + response = self.client.get(reverse("accounts:settings:twitter-revoke")) - self.assertRedirects(response, reverse("accounts:integrations")) + self.assertRedirects(response, reverse("accounts:settings:integrations")) self.mocked_post.assert_not_called() @@ -320,9 +320,9 @@ class TwitterRevokeRedirectView(TestCase): self.mocked_post.side_effect = StreamException - response = self.client.get(reverse("accounts:twitter-revoke")) + response = self.client.get(reverse("accounts:settings:twitter-revoke")) - self.assertRedirects(response, reverse("accounts:integrations")) + self.assertRedirects(response, reverse("accounts:settings:integrations")) self.user.refresh_from_db() @@ -346,7 +346,7 @@ class TwitterAuthRedirectViewTestCase(TestCase): text="oauth_token=foo&oauth_token_secret=bar" ) - response = self.client.get(reverse("accounts:twitter-auth")) + response = self.client.get(reverse("accounts:settings:twitter-auth")) self.assertRedirects( response, @@ -363,9 +363,9 @@ class TwitterAuthRedirectViewTestCase(TestCase): def test_stream_exception(self): self.mocked_post.side_effect = StreamException - response = self.client.get(reverse("accounts:twitter-auth")) + response = self.client.get(reverse("accounts:settings:twitter-auth")) - self.assertRedirects(response, reverse("accounts:integrations")) + self.assertRedirects(response, reverse("accounts:settings:integrations")) cached_token = cache.get(f"twitter-{self.user.email}-token") cached_secret = cache.get(f"twitter-{self.user.email}-secret") @@ -376,9 +376,9 @@ class TwitterAuthRedirectViewTestCase(TestCase): def test_unexpected_contents(self): self.mocked_post.return_value = Mock(text="foo=bar&oauth_token_secret=bar") - response = self.client.get(reverse("accounts:twitter-auth")) + response = self.client.get(reverse("accounts:settings:twitter-auth")) - self.assertRedirects(response, reverse("accounts:integrations")) + self.assertRedirects(response, reverse("accounts:settings:integrations")) cached_token = cache.get(f"twitter-{self.user.email}-token") cached_secret = cache.get(f"twitter-{self.user.email}-secret") @@ -413,7 +413,7 @@ class TwitterTemplateViewTestCase(TestCase): ) response = self.client.get( - f"{reverse('accounts:twitter-template')}?{urlencode(params)}" + f"{reverse('accounts:settings:twitter-template')}?{urlencode(params)}" ) self.assertContains(response, _("Twitter account is linked")) @@ -430,7 +430,7 @@ class TwitterTemplateViewTestCase(TestCase): params = {"denied": "true", "oauth_token": "foo", "oauth_verifier": "barfoo"} response = self.client.get( - f"{reverse('accounts:twitter-template')}?{urlencode(params)}" + f"{reverse('accounts:settings:twitter-template')}?{urlencode(params)}" ) self.assertContains(response, _("Twitter authorization failed")) @@ -453,7 +453,7 @@ class TwitterTemplateViewTestCase(TestCase): params = {"denied": "", "oauth_token": "boo", "oauth_verifier": "barfoo"} response = self.client.get( - f"{reverse('accounts:twitter-template')}?{urlencode(params)}" + f"{reverse('accounts:settings:twitter-template')}?{urlencode(params)}" ) self.assertContains(response, _("OAuth tokens failed to match")) @@ -471,7 +471,7 @@ class TwitterTemplateViewTestCase(TestCase): params = {"denied": "", "oauth_token": "foo", "oauth_verifier": "barfoo"} response = self.client.get( - f"{reverse('accounts:twitter-template')}?{urlencode(params)}" + f"{reverse('accounts:settings:twitter-template')}?{urlencode(params)}" ) self.assertContains(response, _("No matching tokens found for this user")) @@ -495,7 +495,7 @@ class TwitterTemplateViewTestCase(TestCase): self.mocked_post.side_effect = StreamException response = self.client.get( - f"{reverse('accounts:twitter-template')}?{urlencode(params)}" + f"{reverse('accounts:settings:twitter-template')}?{urlencode(params)}" ) self.assertContains(response, _("Failed requesting access token")) @@ -523,7 +523,7 @@ class TwitterTemplateViewTestCase(TestCase): ) response = self.client.get( - f"{reverse('accounts:twitter-template')}?{urlencode(params)}" + f"{reverse('accounts:settings:twitter-template')}?{urlencode(params)}" ) self.assertContains(response, _("No credentials found in Twitter response")) diff --git a/src/newsreader/accounts/tests/test_settings.py b/src/newsreader/accounts/tests/test_settings.py index 42db736..df09289 100644 --- a/src/newsreader/accounts/tests/test_settings.py +++ b/src/newsreader/accounts/tests/test_settings.py @@ -10,7 +10,7 @@ class SettingsViewTestCase(TestCase): self.user = UserFactory(email="test@test.nl", password="test") self.client.force_login(self.user) - self.url = reverse("accounts:settings") + self.url = reverse("accounts:settings:home") def test_simple(self): response = self.client.get(self.url) @@ -25,7 +25,7 @@ class SettingsViewTestCase(TestCase): user = User.objects.get() - self.assertRedirects(response, reverse("accounts:settings")) + self.assertRedirects(response, reverse("accounts:settings:home")) self.assertEquals(user.first_name, "First name") self.assertEquals(user.last_name, "Last name") diff --git a/src/newsreader/accounts/urls.py b/src/newsreader/accounts/urls.py index 3cdd1b1..0eaee5c 100644 --- a/src/newsreader/accounts/urls.py +++ b/src/newsreader/accounts/urls.py @@ -1,10 +1,11 @@ from django.contrib.auth.decorators import login_required -from django.urls import path +from django.urls import include, path from newsreader.accounts.views import ( ActivationCompleteView, ActivationResendView, ActivationView, + FaviconRedirectView, IntegrationsView, LoginView, LogoutView, @@ -26,6 +27,46 @@ from newsreader.accounts.views import ( ) +settings_patterns = [ + # Integrations + path( + "integrations/reddit/callback/", + login_required(RedditTemplateView.as_view()), + name="reddit-template", + ), + path( + "integrations/reddit/refresh/", + login_required(RedditTokenRedirectView.as_view()), + name="reddit-refresh", + ), + path( + "integrations/reddit/revoke/", + login_required(RedditRevokeRedirectView.as_view()), + name="reddit-revoke", + ), + path( + "integrations/twitter/auth/", + login_required(TwitterAuthRedirectView.as_view()), + name="twitter-auth", + ), + path( + "integrations/twitter/callback/", + login_required(TwitterTemplateView.as_view()), + name="twitter-template", + ), + path( + "integrations/twitter/revoke/", + login_required(TwitterRevokeRedirectView.as_view()), + name="twitter-revoke", + ), + path( + "integrations/", login_required(IntegrationsView.as_view()), name="integrations" + ), + # Misc + path("favicon/", login_required(FaviconRedirectView.as_view()), name="favicon"), + path("", login_required(SettingsView.as_view()), name="home"), +] + urlpatterns = [ # Auth path("login/", LoginView.as_view(), name="login"), @@ -70,42 +111,6 @@ urlpatterns = [ login_required(PasswordChangeView.as_view()), name="password-change", ), - # Integrations - path( - "settings/integrations/reddit/callback/", - login_required(RedditTemplateView.as_view()), - name="reddit-template", - ), - path( - "settings/integrations/reddit/refresh/", - login_required(RedditTokenRedirectView.as_view()), - name="reddit-refresh", - ), - path( - "settings/integrations/reddit/revoke/", - login_required(RedditRevokeRedirectView.as_view()), - name="reddit-revoke", - ), - path( - "settings/integrations/twitter/auth/", - login_required(TwitterAuthRedirectView.as_view()), - name="twitter-auth", - ), - path( - "settings/integrations/twitter/callback/", - login_required(TwitterTemplateView.as_view()), - name="twitter-template", - ), - path( - "settings/integrations/twitter/revoke/", - login_required(TwitterRevokeRedirectView.as_view()), - name="twitter-revoke", - ), - path( - "settings/integrations", - login_required(IntegrationsView.as_view()), - name="integrations", - ), # Settings - path("settings/", login_required(SettingsView.as_view()), name="settings"), + path("settings/", include((settings_patterns, "settings"))), ] diff --git a/src/newsreader/accounts/views/__init__.py b/src/newsreader/accounts/views/__init__.py index 81dd1fc..3be2b81 100644 --- a/src/newsreader/accounts/views/__init__.py +++ b/src/newsreader/accounts/views/__init__.py @@ -1,4 +1,5 @@ from newsreader.accounts.views.auth import LoginView, LogoutView +from newsreader.accounts.views.favicon import FaviconRedirectView from newsreader.accounts.views.integrations import ( IntegrationsView, RedditRevokeRedirectView, diff --git a/src/newsreader/accounts/views/favicon.py b/src/newsreader/accounts/views/favicon.py new file mode 100644 index 0000000..1b85399 --- /dev/null +++ b/src/newsreader/accounts/views/favicon.py @@ -0,0 +1,26 @@ +from django.contrib import messages +from django.core.cache import cache +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from django.views.generic import RedirectView + +from newsreader.news.collection.tasks import FaviconTask + + +class FaviconRedirectView(RedirectView): + url = reverse_lazy("accounts:settings:home") + + def get(self, request, *args, **kwargs): + response = super().get(request, *args, **kwargs) + + user = request.user + task_active = cache.get(f"{user.email}-favicon-task") + + if not task_active: + FaviconTask.delay(user.pk) + messages.success(request, _("Favicons are being fetched")) + cache.set(f"{user.email}-favicon-task", 1, 18000) # 5 hours + return response + + messages.error(request, _("Limit reached, try again later")) + return response diff --git a/src/newsreader/accounts/views/integrations.py b/src/newsreader/accounts/views/integrations.py index 62d71fc..e6ed605 100644 --- a/src/newsreader/accounts/views/integrations.py +++ b/src/newsreader/accounts/views/integrations.py @@ -53,7 +53,7 @@ class IntegrationsView(TemplateView): and not user.reddit_access_token and not reddit_task_active ): - reddit_refresh_url = reverse_lazy("accounts:reddit-refresh") + reddit_refresh_url = reverse_lazy("accounts:settings:reddit-refresh") if not user.reddit_refresh_token: reddit_authorization_url = get_reddit_authorization_url(user) @@ -62,7 +62,7 @@ class IntegrationsView(TemplateView): "reddit_authorization_url": reddit_authorization_url, "reddit_refresh_url": reddit_refresh_url, "reddit_revoke_url": ( - reverse_lazy("accounts:reddit-revoke") + reverse_lazy("accounts:settings:reddit-revoke") if not reddit_authorization_url else None ), @@ -72,10 +72,10 @@ class IntegrationsView(TemplateView): twitter_revoke_url = None if self.request.user.has_twitter_auth: - twitter_revoke_url = reverse_lazy("accounts:twitter-revoke") + twitter_revoke_url = reverse_lazy("accounts:settings:twitter-revoke") return { - "twitter_auth_url": reverse_lazy("accounts:twitter-auth"), + "twitter_auth_url": reverse_lazy("accounts:settings:twitter-auth"), "twitter_revoke_url": twitter_revoke_url, } @@ -130,7 +130,7 @@ class RedditTemplateView(TemplateView): class RedditTokenRedirectView(RedirectView): - url = reverse_lazy("accounts:integrations") + url = reverse_lazy("accounts:settings:integrations") def get(self, request, *args, **kwargs): response = super().get(request, *args, **kwargs) @@ -149,7 +149,7 @@ class RedditTokenRedirectView(RedirectView): class RedditRevokeRedirectView(RedirectView): - url = reverse_lazy("accounts:integrations") + url = reverse_lazy("accounts:settings:integrations") def get(self, request, *args, **kwargs): response = super().get(request, *args, **kwargs) @@ -181,7 +181,7 @@ class RedditRevokeRedirectView(RedirectView): class TwitterRevokeRedirectView(RedirectView): - url = reverse_lazy("accounts:integrations") + url = reverse_lazy("accounts:settings:integrations") def get(self, request, *args, **kwargs): if not request.user.has_twitter_auth: @@ -212,7 +212,7 @@ class TwitterRevokeRedirectView(RedirectView): class TwitterAuthRedirectView(RedirectView): - url = reverse_lazy("accounts:integrations") + url = reverse_lazy("accounts:settings:integrations") def get(self, request, *args, **kwargs): oauth = OAuth( diff --git a/src/newsreader/accounts/views/settings.py b/src/newsreader/accounts/views/settings.py index 1603252..eb0b215 100644 --- a/src/newsreader/accounts/views/settings.py +++ b/src/newsreader/accounts/views/settings.py @@ -1,3 +1,4 @@ +from django.core.cache import cache from django.urls import reverse_lazy from django.views.generic.edit import FormView, ModelFormMixin @@ -19,6 +20,14 @@ class SettingsView(ModelFormMixin, FormView): self.object = self.get_object() return super().get(request, *args, **kwargs) + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + return { + **context, + "favicon_task_allowed": not cache.get(f"{self.request.user}-favicon-task"), + } + def get_object(self, **kwargs): return self.request.user diff --git a/src/newsreader/news/collection/favicon.py b/src/newsreader/news/collection/favicon.py index 639e7f6..1ca21e6 100644 --- a/src/newsreader/news/collection/favicon.py +++ b/src/newsreader/news/collection/favicon.py @@ -126,7 +126,7 @@ class FaviconCollector(Collector): feed_client, favicon_client = (FeedClient, FaviconClient) url_builder, favicon_builder = (WebsiteURLBuilder, FaviconBuilder) - def collect(self, rules=None): + def collect(self, rules=[]): streams = [] with self.feed_client(rules=rules) as client: diff --git a/src/newsreader/news/collection/management/commands/collect.py b/src/newsreader/news/collection/management/commands/collect.py deleted file mode 100644 index 7d928f0..0000000 --- a/src/newsreader/news/collection/management/commands/collect.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.core.management.base import BaseCommand - -from newsreader.news.collection.feed import FeedCollector - - -class Command(BaseCommand): - help = "Collects Atom/RSS feeds" - - def handle(self, *args, **options): - collector = FeedCollector() - collector.collect() diff --git a/src/newsreader/news/collection/management/commands/fetch_favicons.py b/src/newsreader/news/collection/management/commands/fetch_favicons.py deleted file mode 100644 index 1ee96cf..0000000 --- a/src/newsreader/news/collection/management/commands/fetch_favicons.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.core.management.base import BaseCommand - -from newsreader.news.collection.favicon import FaviconCollector - - -class Command(BaseCommand): - help = "Fetch favicons for collection rules" - - def handle(self, *args, **options): - collector = FaviconCollector() - collector.collect() diff --git a/src/newsreader/news/collection/tasks.py b/src/newsreader/news/collection/tasks.py index 926b05b..b82bf66 100644 --- a/src/newsreader/news/collection/tasks.py +++ b/src/newsreader/news/collection/tasks.py @@ -147,7 +147,49 @@ class TwitterTimelineTask(app.Task): raise Reject(reason="Task already running", requeue=False) +class FaviconTask(app.Task): + name = "FaviconTask" + ignore_result = True + + def run(self, user_pk): + from newsreader.news.collection.favicon import FaviconCollector + + try: + user = User.objects.get(pk=user_pk) + except ObjectDoesNotExist: + message = f"User {user_pk} does not exist" + logger.exception(message) + + raise Reject(reason=message, requeue=False) + + with MemCacheLock("f{user.email}-favicon-task", self.app.oid) as acquired: + if acquired: + logger.info(f"Running favicon task for user {user_pk}") + + rules = user.rules.enabled().filter(type=RuleTypeChoices.feed) + + collector = FaviconCollector() + collector.collect(rules=rules) + + third_party_rules = user.rules.enabled().exclude( + type=RuleTypeChoices.feed + ) + + for rule in third_party_rules: + if rule.type == RuleTypeChoices.subreddit: + rule.favicon = "https://www.reddit.com/favicon.ico" + rule.save() + elif rule.type == RuleTypeChoices.twitter_timeline: + rule.favicon = "https://abs.twimg.com/favicons/favicon.ico" + rule.save() + else: + logger.warning(f"Cancelling task due to existing lock") + + raise Reject(reason="Task already running", requeue=False) + + FeedTask = app.register_task(FeedTask()) +FaviconTask = app.register_task(FaviconTask()) RedditTask = app.register_task(RedditTask()) RedditTokenTask = app.register_task(RedditTokenTask()) TwitterTimelineTask = app.register_task(TwitterTimelineTask()) diff --git a/src/newsreader/templates/base.html b/src/newsreader/templates/base.html index 3f677c0..efaf9f2 100644 --- a/src/newsreader/templates/base.html +++ b/src/newsreader/templates/base.html @@ -17,7 +17,7 @@ - + {% if request.user.is_superuser %} {% endif %} From f12639987fc01ead2ec462a36061f465eed8e116 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Tue, 6 Oct 2020 22:34:24 +0200 Subject: [PATCH 180/422] Update messages styling --- src/newsreader/js/components/Messages.js | 2 +- .../scss/components/messages/_messages.scss | 31 ++++++++++++++++--- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/newsreader/js/components/Messages.js b/src/newsreader/js/components/Messages.js index 843677c..150b003 100644 --- a/src/newsreader/js/components/Messages.js +++ b/src/newsreader/js/components/Messages.js @@ -22,7 +22,7 @@ class Messages extends React.Component { ); }); - return
        {messages}
      ; + return
        {messages}
      ; } } diff --git a/src/newsreader/scss/components/messages/_messages.scss b/src/newsreader/scss/components/messages/_messages.scss index 74d88b5..b1ba9d0 100644 --- a/src/newsreader/scss/components/messages/_messages.scss +++ b/src/newsreader/scss/components/messages/_messages.scss @@ -3,12 +3,10 @@ flex-direction: column; align-items: center; - position: fixed; - top: 0; width: 100%; margin: 5px 0 20px 0; - color: $white; + color: $font-color; &__item { width: 80%; @@ -17,7 +15,7 @@ padding: 20px 15px; margin: 5px 0; - background-color: $blue; + background-color: $transparant-blue; &--error { background-color: $transparant-red; @@ -27,7 +25,6 @@ background-color: $transparant-orange; } - // TODO check this color &--success { background-color: $transparant-green; } @@ -39,4 +36,28 @@ --ggs: 2; } } + + &--fixed { + position: fixed; + top: 0; + } + + &--fixed &__item { + color: $white; + background-color: $blue; + } + + &--fixed &__item--error { + color: $white; + background-color: $red; + } + + &--fixed &__item--warning { + background-color: $orange; + } + + &--fixed &__item--success { + color: $white; + background-color: $green; + } } From 593b06006ced387c9cf0fefb8b720622d7f5c981 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Tue, 6 Oct 2020 22:37:14 +0200 Subject: [PATCH 181/422] Fix broken view --- src/newsreader/accounts/views/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/newsreader/accounts/views/settings.py b/src/newsreader/accounts/views/settings.py index eb0b215..aac24fb 100644 --- a/src/newsreader/accounts/views/settings.py +++ b/src/newsreader/accounts/views/settings.py @@ -12,7 +12,7 @@ from newsreader.news.collection.reddit import ( class SettingsView(ModelFormMixin, FormView): template_name = "accounts/views/settings.html" - success_url = reverse_lazy("accounts:settings") + success_url = reverse_lazy("accounts:settings:home") form_class = UserSettingsForm model = User From 1c3a33c1d8a4ec2c7d40633cc19acafa7d319967 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Tue, 6 Oct 2020 22:46:33 +0200 Subject: [PATCH 182/422] Fix failing test --- src/newsreader/accounts/tests/test_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/newsreader/accounts/tests/test_settings.py b/src/newsreader/accounts/tests/test_settings.py index df09289..5a12637 100644 --- a/src/newsreader/accounts/tests/test_settings.py +++ b/src/newsreader/accounts/tests/test_settings.py @@ -19,7 +19,7 @@ class SettingsViewTestCase(TestCase): def test_user_credential_change(self): response = self.client.post( - reverse("accounts:settings"), + reverse("accounts:settings:home"), {"first_name": "First name", "last_name": "Last name"}, ) From b6921a20e732cc4165023b044e091b06ae24602d Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Tue, 6 Oct 2020 22:51:23 +0200 Subject: [PATCH 183/422] 0.3.2 - Add user runnable favicon task - Update messages styling --- .../accounts/components/settings-form.html | 17 +++- .../accounts/views/password-change.html | 2 +- .../templates/accounts/views/reddit.html | 2 +- .../templates/accounts/views/twitter.html | 2 +- src/newsreader/accounts/tests/test_favicon.py | 37 +++++++++ .../accounts/tests/test_integrations.py | 62 +++++++------- .../accounts/tests/test_settings.py | 6 +- src/newsreader/accounts/urls.py | 81 ++++++++++--------- src/newsreader/accounts/views/__init__.py | 1 + src/newsreader/accounts/views/favicon.py | 26 ++++++ src/newsreader/accounts/views/integrations.py | 16 ++-- src/newsreader/accounts/views/settings.py | 11 ++- src/newsreader/js/components/Messages.js | 2 +- src/newsreader/news/collection/favicon.py | 2 +- .../collection/management/commands/collect.py | 11 --- .../management/commands/fetch_favicons.py | 11 --- src/newsreader/news/collection/tasks.py | 42 ++++++++++ .../scss/components/messages/_messages.scss | 31 +++++-- src/newsreader/templates/base.html | 2 +- 19 files changed, 247 insertions(+), 117 deletions(-) create mode 100644 src/newsreader/accounts/tests/test_favicon.py create mode 100644 src/newsreader/accounts/views/favicon.py delete mode 100644 src/newsreader/news/collection/management/commands/collect.py delete mode 100644 src/newsreader/news/collection/management/commands/fetch_favicons.py diff --git a/src/newsreader/accounts/templates/accounts/components/settings-form.html b/src/newsreader/accounts/templates/accounts/components/settings-form.html index 51d4450..f5e7065 100644 --- a/src/newsreader/accounts/templates/accounts/components/settings-form.html +++ b/src/newsreader/accounts/templates/accounts/components/settings-form.html @@ -4,14 +4,25 @@ {% block actions %}
      + {% include "components/form/confirm-button.html" %} + {% trans "Change password" %} - + + {% if favicon_task_allowed %} + + {% trans "Fetch favicons" %} + + {% else %} + + {% endif %} + + {% trans "Third party integrations" %} - - {% include "components/form/confirm-button.html" %}
      {% endblock actions %} diff --git a/src/newsreader/accounts/templates/accounts/views/password-change.html b/src/newsreader/accounts/templates/accounts/views/password-change.html index fb8a98b..d6eb918 100644 --- a/src/newsreader/accounts/templates/accounts/views/password-change.html +++ b/src/newsreader/accounts/templates/accounts/views/password-change.html @@ -2,7 +2,7 @@ {% block content %}
      - {% url 'accounts:settings' as cancel_url %} + {% url 'accounts:settings:home' as cancel_url %} {% include "components/form/form.html" with form=form title="Change password" confirm_text="Change password" cancel_url=cancel_url %}
      {% endblock %} diff --git a/src/newsreader/accounts/templates/accounts/views/reddit.html b/src/newsreader/accounts/templates/accounts/views/reddit.html index 5d4f539..353ca72 100644 --- a/src/newsreader/accounts/templates/accounts/views/reddit.html +++ b/src/newsreader/accounts/templates/accounts/views/reddit.html @@ -13,7 +13,7 @@ {% endif %}

      - {% trans "Return to integrations page" %} + {% trans "Return to integrations page" %}

      diff --git a/src/newsreader/accounts/templates/accounts/views/twitter.html b/src/newsreader/accounts/templates/accounts/views/twitter.html index e2c51aa..6df1a97 100644 --- a/src/newsreader/accounts/templates/accounts/views/twitter.html +++ b/src/newsreader/accounts/templates/accounts/views/twitter.html @@ -13,7 +13,7 @@ {% endif %}

      - {% trans "Return to integrations page" %} + {% trans "Return to integrations page" %}

      diff --git a/src/newsreader/accounts/tests/test_favicon.py b/src/newsreader/accounts/tests/test_favicon.py new file mode 100644 index 0000000..d3eb56b --- /dev/null +++ b/src/newsreader/accounts/tests/test_favicon.py @@ -0,0 +1,37 @@ +from unittest.mock import patch + +from django.core.cache import cache +from django.test import TestCase +from django.urls import reverse + +from newsreader.accounts.tests.factories import UserFactory + + +class FaviconRedirectViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(email="test@test.nl", password="test") + self.client.force_login(self.user) + + self.patch = patch("newsreader.accounts.views.favicon.FaviconTask") + self.mocked_task = self.patch.start() + + def tearDown(self): + cache.clear() + + def test_simple(self): + response = self.client.get(reverse("accounts:settings:favicon")) + + self.assertRedirects(response, reverse("accounts:settings:home")) + + self.mocked_task.delay.assert_called_once_with(self.user.pk) + + self.assertEqual(1, cache.get(f"{self.user.email}-favicon-task")) + + def test_not_active(self): + cache.set(f"{self.user.email}-favicon-task", 1) + + response = self.client.get(reverse("accounts:settings:favicon")) + + self.assertRedirects(response, reverse("accounts:settings:home")) + + self.mocked_task.delay.assert_not_called() diff --git a/src/newsreader/accounts/tests/test_integrations.py b/src/newsreader/accounts/tests/test_integrations.py index cdc9546..fbee223 100644 --- a/src/newsreader/accounts/tests/test_integrations.py +++ b/src/newsreader/accounts/tests/test_integrations.py @@ -22,7 +22,7 @@ class IntegrationsViewTestCase(TestCase): self.user = UserFactory(email="test@test.nl", password="test") self.client.force_login(self.user) - self.url = reverse("accounts:integrations") + self.url = reverse("accounts:settings:integrations") class RedditIntegrationsTestCase(IntegrationsViewTestCase): @@ -69,7 +69,7 @@ class RedditTemplateViewTestCase(TestCase): self.user = UserFactory(email="test@test.nl", password="test") self.client.force_login(self.user) - self.base_url = reverse("accounts:reddit-template") + self.base_url = reverse("accounts:settings:reddit-template") self.state = str(uuid4()) self.patch = patch("newsreader.news.collection.reddit.post") @@ -190,9 +190,9 @@ class RedditTokenRedirectViewTestCase(TestCase): cache.clear() def test_simple(self): - response = self.client.get(reverse("accounts:reddit-refresh")) + response = self.client.get(reverse("accounts:settings:reddit-refresh")) - self.assertRedirects(response, reverse("accounts:integrations")) + self.assertRedirects(response, reverse("accounts:settings:integrations")) self.mocked_task.delay.assert_called_once_with(self.user.pk) @@ -201,9 +201,9 @@ class RedditTokenRedirectViewTestCase(TestCase): def test_not_active(self): cache.set(f"{self.user.email}-reddit-refresh", 1) - response = self.client.get(reverse("accounts:reddit-refresh")) + response = self.client.get(reverse("accounts:settings:reddit-refresh")) - self.assertRedirects(response, reverse("accounts:integrations")) + self.assertRedirects(response, reverse("accounts:settings:integrations")) self.mocked_task.delay.assert_not_called() @@ -223,9 +223,9 @@ class RedditRevokeRedirectViewTestCase(TestCase): self.mocked_revoke.return_value = True - response = self.client.get(reverse("accounts:reddit-revoke")) + response = self.client.get(reverse("accounts:settings:reddit-revoke")) - self.assertRedirects(response, reverse("accounts:integrations")) + self.assertRedirects(response, reverse("accounts:settings:integrations")) self.mocked_revoke.assert_called_once_with(self.user) @@ -238,9 +238,9 @@ class RedditRevokeRedirectViewTestCase(TestCase): self.user.reddit_refresh_token = None self.user.save() - response = self.client.get(reverse("accounts:reddit-revoke")) + response = self.client.get(reverse("accounts:settings:reddit-revoke")) - self.assertRedirects(response, reverse("accounts:integrations")) + self.assertRedirects(response, reverse("accounts:settings:integrations")) self.mocked_revoke.assert_not_called() @@ -251,9 +251,9 @@ class RedditRevokeRedirectViewTestCase(TestCase): self.mocked_revoke.return_value = False - response = self.client.get(reverse("accounts:reddit-revoke")) + response = self.client.get(reverse("accounts:settings:reddit-revoke")) - self.assertRedirects(response, reverse("accounts:integrations")) + self.assertRedirects(response, reverse("accounts:settings:integrations")) self.user.refresh_from_db() @@ -267,9 +267,9 @@ class RedditRevokeRedirectViewTestCase(TestCase): self.mocked_revoke.side_effect = StreamException - response = self.client.get(reverse("accounts:reddit-revoke")) + response = self.client.get(reverse("accounts:settings:reddit-revoke")) - self.assertRedirects(response, reverse("accounts:integrations")) + self.assertRedirects(response, reverse("accounts:settings:integrations")) self.user.refresh_from_db() @@ -293,9 +293,9 @@ class TwitterRevokeRedirectView(TestCase): self.user.twitter_oauth_token_secret = "jadajadajada" self.user.save() - response = self.client.get(reverse("accounts:twitter-revoke")) + response = self.client.get(reverse("accounts:settings:twitter-revoke")) - self.assertRedirects(response, reverse("accounts:integrations")) + self.assertRedirects(response, reverse("accounts:settings:integrations")) self.user.refresh_from_db() @@ -307,9 +307,9 @@ class TwitterRevokeRedirectView(TestCase): self.user.twitter_oauth_token_secret = None self.user.save() - response = self.client.get(reverse("accounts:twitter-revoke")) + response = self.client.get(reverse("accounts:settings:twitter-revoke")) - self.assertRedirects(response, reverse("accounts:integrations")) + self.assertRedirects(response, reverse("accounts:settings:integrations")) self.mocked_post.assert_not_called() @@ -320,9 +320,9 @@ class TwitterRevokeRedirectView(TestCase): self.mocked_post.side_effect = StreamException - response = self.client.get(reverse("accounts:twitter-revoke")) + response = self.client.get(reverse("accounts:settings:twitter-revoke")) - self.assertRedirects(response, reverse("accounts:integrations")) + self.assertRedirects(response, reverse("accounts:settings:integrations")) self.user.refresh_from_db() @@ -346,7 +346,7 @@ class TwitterAuthRedirectViewTestCase(TestCase): text="oauth_token=foo&oauth_token_secret=bar" ) - response = self.client.get(reverse("accounts:twitter-auth")) + response = self.client.get(reverse("accounts:settings:twitter-auth")) self.assertRedirects( response, @@ -363,9 +363,9 @@ class TwitterAuthRedirectViewTestCase(TestCase): def test_stream_exception(self): self.mocked_post.side_effect = StreamException - response = self.client.get(reverse("accounts:twitter-auth")) + response = self.client.get(reverse("accounts:settings:twitter-auth")) - self.assertRedirects(response, reverse("accounts:integrations")) + self.assertRedirects(response, reverse("accounts:settings:integrations")) cached_token = cache.get(f"twitter-{self.user.email}-token") cached_secret = cache.get(f"twitter-{self.user.email}-secret") @@ -376,9 +376,9 @@ class TwitterAuthRedirectViewTestCase(TestCase): def test_unexpected_contents(self): self.mocked_post.return_value = Mock(text="foo=bar&oauth_token_secret=bar") - response = self.client.get(reverse("accounts:twitter-auth")) + response = self.client.get(reverse("accounts:settings:twitter-auth")) - self.assertRedirects(response, reverse("accounts:integrations")) + self.assertRedirects(response, reverse("accounts:settings:integrations")) cached_token = cache.get(f"twitter-{self.user.email}-token") cached_secret = cache.get(f"twitter-{self.user.email}-secret") @@ -413,7 +413,7 @@ class TwitterTemplateViewTestCase(TestCase): ) response = self.client.get( - f"{reverse('accounts:twitter-template')}?{urlencode(params)}" + f"{reverse('accounts:settings:twitter-template')}?{urlencode(params)}" ) self.assertContains(response, _("Twitter account is linked")) @@ -430,7 +430,7 @@ class TwitterTemplateViewTestCase(TestCase): params = {"denied": "true", "oauth_token": "foo", "oauth_verifier": "barfoo"} response = self.client.get( - f"{reverse('accounts:twitter-template')}?{urlencode(params)}" + f"{reverse('accounts:settings:twitter-template')}?{urlencode(params)}" ) self.assertContains(response, _("Twitter authorization failed")) @@ -453,7 +453,7 @@ class TwitterTemplateViewTestCase(TestCase): params = {"denied": "", "oauth_token": "boo", "oauth_verifier": "barfoo"} response = self.client.get( - f"{reverse('accounts:twitter-template')}?{urlencode(params)}" + f"{reverse('accounts:settings:twitter-template')}?{urlencode(params)}" ) self.assertContains(response, _("OAuth tokens failed to match")) @@ -471,7 +471,7 @@ class TwitterTemplateViewTestCase(TestCase): params = {"denied": "", "oauth_token": "foo", "oauth_verifier": "barfoo"} response = self.client.get( - f"{reverse('accounts:twitter-template')}?{urlencode(params)}" + f"{reverse('accounts:settings:twitter-template')}?{urlencode(params)}" ) self.assertContains(response, _("No matching tokens found for this user")) @@ -495,7 +495,7 @@ class TwitterTemplateViewTestCase(TestCase): self.mocked_post.side_effect = StreamException response = self.client.get( - f"{reverse('accounts:twitter-template')}?{urlencode(params)}" + f"{reverse('accounts:settings:twitter-template')}?{urlencode(params)}" ) self.assertContains(response, _("Failed requesting access token")) @@ -523,7 +523,7 @@ class TwitterTemplateViewTestCase(TestCase): ) response = self.client.get( - f"{reverse('accounts:twitter-template')}?{urlencode(params)}" + f"{reverse('accounts:settings:twitter-template')}?{urlencode(params)}" ) self.assertContains(response, _("No credentials found in Twitter response")) diff --git a/src/newsreader/accounts/tests/test_settings.py b/src/newsreader/accounts/tests/test_settings.py index 42db736..5a12637 100644 --- a/src/newsreader/accounts/tests/test_settings.py +++ b/src/newsreader/accounts/tests/test_settings.py @@ -10,7 +10,7 @@ class SettingsViewTestCase(TestCase): self.user = UserFactory(email="test@test.nl", password="test") self.client.force_login(self.user) - self.url = reverse("accounts:settings") + self.url = reverse("accounts:settings:home") def test_simple(self): response = self.client.get(self.url) @@ -19,13 +19,13 @@ class SettingsViewTestCase(TestCase): def test_user_credential_change(self): response = self.client.post( - reverse("accounts:settings"), + reverse("accounts:settings:home"), {"first_name": "First name", "last_name": "Last name"}, ) user = User.objects.get() - self.assertRedirects(response, reverse("accounts:settings")) + self.assertRedirects(response, reverse("accounts:settings:home")) self.assertEquals(user.first_name, "First name") self.assertEquals(user.last_name, "Last name") diff --git a/src/newsreader/accounts/urls.py b/src/newsreader/accounts/urls.py index 3cdd1b1..0eaee5c 100644 --- a/src/newsreader/accounts/urls.py +++ b/src/newsreader/accounts/urls.py @@ -1,10 +1,11 @@ from django.contrib.auth.decorators import login_required -from django.urls import path +from django.urls import include, path from newsreader.accounts.views import ( ActivationCompleteView, ActivationResendView, ActivationView, + FaviconRedirectView, IntegrationsView, LoginView, LogoutView, @@ -26,6 +27,46 @@ from newsreader.accounts.views import ( ) +settings_patterns = [ + # Integrations + path( + "integrations/reddit/callback/", + login_required(RedditTemplateView.as_view()), + name="reddit-template", + ), + path( + "integrations/reddit/refresh/", + login_required(RedditTokenRedirectView.as_view()), + name="reddit-refresh", + ), + path( + "integrations/reddit/revoke/", + login_required(RedditRevokeRedirectView.as_view()), + name="reddit-revoke", + ), + path( + "integrations/twitter/auth/", + login_required(TwitterAuthRedirectView.as_view()), + name="twitter-auth", + ), + path( + "integrations/twitter/callback/", + login_required(TwitterTemplateView.as_view()), + name="twitter-template", + ), + path( + "integrations/twitter/revoke/", + login_required(TwitterRevokeRedirectView.as_view()), + name="twitter-revoke", + ), + path( + "integrations/", login_required(IntegrationsView.as_view()), name="integrations" + ), + # Misc + path("favicon/", login_required(FaviconRedirectView.as_view()), name="favicon"), + path("", login_required(SettingsView.as_view()), name="home"), +] + urlpatterns = [ # Auth path("login/", LoginView.as_view(), name="login"), @@ -70,42 +111,6 @@ urlpatterns = [ login_required(PasswordChangeView.as_view()), name="password-change", ), - # Integrations - path( - "settings/integrations/reddit/callback/", - login_required(RedditTemplateView.as_view()), - name="reddit-template", - ), - path( - "settings/integrations/reddit/refresh/", - login_required(RedditTokenRedirectView.as_view()), - name="reddit-refresh", - ), - path( - "settings/integrations/reddit/revoke/", - login_required(RedditRevokeRedirectView.as_view()), - name="reddit-revoke", - ), - path( - "settings/integrations/twitter/auth/", - login_required(TwitterAuthRedirectView.as_view()), - name="twitter-auth", - ), - path( - "settings/integrations/twitter/callback/", - login_required(TwitterTemplateView.as_view()), - name="twitter-template", - ), - path( - "settings/integrations/twitter/revoke/", - login_required(TwitterRevokeRedirectView.as_view()), - name="twitter-revoke", - ), - path( - "settings/integrations", - login_required(IntegrationsView.as_view()), - name="integrations", - ), # Settings - path("settings/", login_required(SettingsView.as_view()), name="settings"), + path("settings/", include((settings_patterns, "settings"))), ] diff --git a/src/newsreader/accounts/views/__init__.py b/src/newsreader/accounts/views/__init__.py index 81dd1fc..3be2b81 100644 --- a/src/newsreader/accounts/views/__init__.py +++ b/src/newsreader/accounts/views/__init__.py @@ -1,4 +1,5 @@ from newsreader.accounts.views.auth import LoginView, LogoutView +from newsreader.accounts.views.favicon import FaviconRedirectView from newsreader.accounts.views.integrations import ( IntegrationsView, RedditRevokeRedirectView, diff --git a/src/newsreader/accounts/views/favicon.py b/src/newsreader/accounts/views/favicon.py new file mode 100644 index 0000000..1b85399 --- /dev/null +++ b/src/newsreader/accounts/views/favicon.py @@ -0,0 +1,26 @@ +from django.contrib import messages +from django.core.cache import cache +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from django.views.generic import RedirectView + +from newsreader.news.collection.tasks import FaviconTask + + +class FaviconRedirectView(RedirectView): + url = reverse_lazy("accounts:settings:home") + + def get(self, request, *args, **kwargs): + response = super().get(request, *args, **kwargs) + + user = request.user + task_active = cache.get(f"{user.email}-favicon-task") + + if not task_active: + FaviconTask.delay(user.pk) + messages.success(request, _("Favicons are being fetched")) + cache.set(f"{user.email}-favicon-task", 1, 18000) # 5 hours + return response + + messages.error(request, _("Limit reached, try again later")) + return response diff --git a/src/newsreader/accounts/views/integrations.py b/src/newsreader/accounts/views/integrations.py index 62d71fc..e6ed605 100644 --- a/src/newsreader/accounts/views/integrations.py +++ b/src/newsreader/accounts/views/integrations.py @@ -53,7 +53,7 @@ class IntegrationsView(TemplateView): and not user.reddit_access_token and not reddit_task_active ): - reddit_refresh_url = reverse_lazy("accounts:reddit-refresh") + reddit_refresh_url = reverse_lazy("accounts:settings:reddit-refresh") if not user.reddit_refresh_token: reddit_authorization_url = get_reddit_authorization_url(user) @@ -62,7 +62,7 @@ class IntegrationsView(TemplateView): "reddit_authorization_url": reddit_authorization_url, "reddit_refresh_url": reddit_refresh_url, "reddit_revoke_url": ( - reverse_lazy("accounts:reddit-revoke") + reverse_lazy("accounts:settings:reddit-revoke") if not reddit_authorization_url else None ), @@ -72,10 +72,10 @@ class IntegrationsView(TemplateView): twitter_revoke_url = None if self.request.user.has_twitter_auth: - twitter_revoke_url = reverse_lazy("accounts:twitter-revoke") + twitter_revoke_url = reverse_lazy("accounts:settings:twitter-revoke") return { - "twitter_auth_url": reverse_lazy("accounts:twitter-auth"), + "twitter_auth_url": reverse_lazy("accounts:settings:twitter-auth"), "twitter_revoke_url": twitter_revoke_url, } @@ -130,7 +130,7 @@ class RedditTemplateView(TemplateView): class RedditTokenRedirectView(RedirectView): - url = reverse_lazy("accounts:integrations") + url = reverse_lazy("accounts:settings:integrations") def get(self, request, *args, **kwargs): response = super().get(request, *args, **kwargs) @@ -149,7 +149,7 @@ class RedditTokenRedirectView(RedirectView): class RedditRevokeRedirectView(RedirectView): - url = reverse_lazy("accounts:integrations") + url = reverse_lazy("accounts:settings:integrations") def get(self, request, *args, **kwargs): response = super().get(request, *args, **kwargs) @@ -181,7 +181,7 @@ class RedditRevokeRedirectView(RedirectView): class TwitterRevokeRedirectView(RedirectView): - url = reverse_lazy("accounts:integrations") + url = reverse_lazy("accounts:settings:integrations") def get(self, request, *args, **kwargs): if not request.user.has_twitter_auth: @@ -212,7 +212,7 @@ class TwitterRevokeRedirectView(RedirectView): class TwitterAuthRedirectView(RedirectView): - url = reverse_lazy("accounts:integrations") + url = reverse_lazy("accounts:settings:integrations") def get(self, request, *args, **kwargs): oauth = OAuth( diff --git a/src/newsreader/accounts/views/settings.py b/src/newsreader/accounts/views/settings.py index 1603252..aac24fb 100644 --- a/src/newsreader/accounts/views/settings.py +++ b/src/newsreader/accounts/views/settings.py @@ -1,3 +1,4 @@ +from django.core.cache import cache from django.urls import reverse_lazy from django.views.generic.edit import FormView, ModelFormMixin @@ -11,7 +12,7 @@ from newsreader.news.collection.reddit import ( class SettingsView(ModelFormMixin, FormView): template_name = "accounts/views/settings.html" - success_url = reverse_lazy("accounts:settings") + success_url = reverse_lazy("accounts:settings:home") form_class = UserSettingsForm model = User @@ -19,6 +20,14 @@ class SettingsView(ModelFormMixin, FormView): self.object = self.get_object() return super().get(request, *args, **kwargs) + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + return { + **context, + "favicon_task_allowed": not cache.get(f"{self.request.user}-favicon-task"), + } + def get_object(self, **kwargs): return self.request.user diff --git a/src/newsreader/js/components/Messages.js b/src/newsreader/js/components/Messages.js index 843677c..150b003 100644 --- a/src/newsreader/js/components/Messages.js +++ b/src/newsreader/js/components/Messages.js @@ -22,7 +22,7 @@ class Messages extends React.Component { ); }); - return
        {messages}
      ; + return
        {messages}
      ; } } diff --git a/src/newsreader/news/collection/favicon.py b/src/newsreader/news/collection/favicon.py index 639e7f6..1ca21e6 100644 --- a/src/newsreader/news/collection/favicon.py +++ b/src/newsreader/news/collection/favicon.py @@ -126,7 +126,7 @@ class FaviconCollector(Collector): feed_client, favicon_client = (FeedClient, FaviconClient) url_builder, favicon_builder = (WebsiteURLBuilder, FaviconBuilder) - def collect(self, rules=None): + def collect(self, rules=[]): streams = [] with self.feed_client(rules=rules) as client: diff --git a/src/newsreader/news/collection/management/commands/collect.py b/src/newsreader/news/collection/management/commands/collect.py deleted file mode 100644 index 7d928f0..0000000 --- a/src/newsreader/news/collection/management/commands/collect.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.core.management.base import BaseCommand - -from newsreader.news.collection.feed import FeedCollector - - -class Command(BaseCommand): - help = "Collects Atom/RSS feeds" - - def handle(self, *args, **options): - collector = FeedCollector() - collector.collect() diff --git a/src/newsreader/news/collection/management/commands/fetch_favicons.py b/src/newsreader/news/collection/management/commands/fetch_favicons.py deleted file mode 100644 index 1ee96cf..0000000 --- a/src/newsreader/news/collection/management/commands/fetch_favicons.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.core.management.base import BaseCommand - -from newsreader.news.collection.favicon import FaviconCollector - - -class Command(BaseCommand): - help = "Fetch favicons for collection rules" - - def handle(self, *args, **options): - collector = FaviconCollector() - collector.collect() diff --git a/src/newsreader/news/collection/tasks.py b/src/newsreader/news/collection/tasks.py index 926b05b..b82bf66 100644 --- a/src/newsreader/news/collection/tasks.py +++ b/src/newsreader/news/collection/tasks.py @@ -147,7 +147,49 @@ class TwitterTimelineTask(app.Task): raise Reject(reason="Task already running", requeue=False) +class FaviconTask(app.Task): + name = "FaviconTask" + ignore_result = True + + def run(self, user_pk): + from newsreader.news.collection.favicon import FaviconCollector + + try: + user = User.objects.get(pk=user_pk) + except ObjectDoesNotExist: + message = f"User {user_pk} does not exist" + logger.exception(message) + + raise Reject(reason=message, requeue=False) + + with MemCacheLock("f{user.email}-favicon-task", self.app.oid) as acquired: + if acquired: + logger.info(f"Running favicon task for user {user_pk}") + + rules = user.rules.enabled().filter(type=RuleTypeChoices.feed) + + collector = FaviconCollector() + collector.collect(rules=rules) + + third_party_rules = user.rules.enabled().exclude( + type=RuleTypeChoices.feed + ) + + for rule in third_party_rules: + if rule.type == RuleTypeChoices.subreddit: + rule.favicon = "https://www.reddit.com/favicon.ico" + rule.save() + elif rule.type == RuleTypeChoices.twitter_timeline: + rule.favicon = "https://abs.twimg.com/favicons/favicon.ico" + rule.save() + else: + logger.warning(f"Cancelling task due to existing lock") + + raise Reject(reason="Task already running", requeue=False) + + FeedTask = app.register_task(FeedTask()) +FaviconTask = app.register_task(FaviconTask()) RedditTask = app.register_task(RedditTask()) RedditTokenTask = app.register_task(RedditTokenTask()) TwitterTimelineTask = app.register_task(TwitterTimelineTask()) diff --git a/src/newsreader/scss/components/messages/_messages.scss b/src/newsreader/scss/components/messages/_messages.scss index 74d88b5..b1ba9d0 100644 --- a/src/newsreader/scss/components/messages/_messages.scss +++ b/src/newsreader/scss/components/messages/_messages.scss @@ -3,12 +3,10 @@ flex-direction: column; align-items: center; - position: fixed; - top: 0; width: 100%; margin: 5px 0 20px 0; - color: $white; + color: $font-color; &__item { width: 80%; @@ -17,7 +15,7 @@ padding: 20px 15px; margin: 5px 0; - background-color: $blue; + background-color: $transparant-blue; &--error { background-color: $transparant-red; @@ -27,7 +25,6 @@ background-color: $transparant-orange; } - // TODO check this color &--success { background-color: $transparant-green; } @@ -39,4 +36,28 @@ --ggs: 2; } } + + &--fixed { + position: fixed; + top: 0; + } + + &--fixed &__item { + color: $white; + background-color: $blue; + } + + &--fixed &__item--error { + color: $white; + background-color: $red; + } + + &--fixed &__item--warning { + background-color: $orange; + } + + &--fixed &__item--success { + color: $white; + background-color: $green; + } } diff --git a/src/newsreader/templates/base.html b/src/newsreader/templates/base.html index 3f677c0..efaf9f2 100644 --- a/src/newsreader/templates/base.html +++ b/src/newsreader/templates/base.html @@ -17,7 +17,7 @@ - + {% if request.user.is_superuser %} {% endif %} From 763d8ee093a83d921cfa28424c5ebe7f0a4af72a Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Thu, 15 Oct 2020 19:30:53 +0200 Subject: [PATCH 184/422] Refactor builders to use custom exceptions --- .../news/collection/exceptions/__init__.py | 16 ++ .../news/collection/exceptions/builder.py | 21 ++ .../{exceptions.py => exceptions/stream.py} | 0 src/newsreader/news/collection/feed.py | 60 ++--- src/newsreader/news/collection/reddit.py | 213 ++++++++++------- .../collection/tests/feed/builder/tests.py | 221 +++++++----------- .../collection/tests/reddit/builder/tests.py | 57 ----- .../collection/tests/twitter/builder/mocks.py | 199 ++++++++++++++++ .../collection/tests/twitter/builder/tests.py | 19 ++ src/newsreader/news/collection/twitter.py | 79 ++++--- 10 files changed, 554 insertions(+), 331 deletions(-) create mode 100644 src/newsreader/news/collection/exceptions/__init__.py create mode 100644 src/newsreader/news/collection/exceptions/builder.py rename src/newsreader/news/collection/{exceptions.py => exceptions/stream.py} (100%) diff --git a/src/newsreader/news/collection/exceptions/__init__.py b/src/newsreader/news/collection/exceptions/__init__.py new file mode 100644 index 0000000..35ce72d --- /dev/null +++ b/src/newsreader/news/collection/exceptions/__init__.py @@ -0,0 +1,16 @@ +from newsreader.news.collection.exceptions.builder import ( + BuilderDuplicateException, + BuilderException, + BuilderMissingDataException, + BuilderParseException, +) +from newsreader.news.collection.exceptions.stream import ( + StreamConnectionException, + StreamDeniedException, + StreamException, + StreamForbiddenException, + StreamNotFoundException, + StreamParseException, + StreamTimeOutException, + StreamTooManyException, +) diff --git a/src/newsreader/news/collection/exceptions/builder.py b/src/newsreader/news/collection/exceptions/builder.py new file mode 100644 index 0000000..6fb2d60 --- /dev/null +++ b/src/newsreader/news/collection/exceptions/builder.py @@ -0,0 +1,21 @@ +class BuilderException(Exception): + message = "Builder exception" + + def __init__(self, payload=None, message=None): + self.payload = payload + self.message = message if message else self.message + + def __str__(self): + return self.message + + +class BuilderMissingDataException(BuilderException): + message = "Payload contains missing data" + + +class BuilderDuplicateException(BuilderException): + message = "Payload contains duplicate entry" + + +class BuilderParseException(BuilderException): + message = "Failed to parse payload" diff --git a/src/newsreader/news/collection/exceptions.py b/src/newsreader/news/collection/exceptions/stream.py similarity index 100% rename from src/newsreader/news/collection/exceptions.py rename to src/newsreader/news/collection/exceptions/stream.py diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index ae6cd42..379f18e 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -39,6 +39,18 @@ class FeedBuilder(PostBuilder): rule__type = RuleTypeChoices.feed def build(self): + instances = [] + + with FeedDuplicateHandler(self.stream.rule) as duplicate_handler: + entries = self.payload.get("entries", []) + + for entry in entries: + post = self.build_post(entry) + instances.append(post) + + self.instances = duplicate_handler.check(instances) + + def build_post(self, entry): field_mapping = { "id": "remote_identifier", "title": "title", @@ -48,41 +60,37 @@ class FeedBuilder(PostBuilder): "author": "author", } tz = pytz.timezone(self.stream.rule.timezone) - instances = [] + data = {"rule_id": self.stream.rule.pk} - with FeedDuplicateHandler(self.stream.rule) as duplicate_handler: - entries = self.payload.get("entries", []) + for field, model_field in field_mapping.items(): + if not field in entry: + continue - for entry in entries: - data = {"rule_id": self.stream.rule.pk} + value = truncate_text(Post, model_field, entry[field]) - for field, model_field in field_mapping.items(): - if not field in entry: - continue + if field == "published_parsed": + data[model_field] = build_publication_date(value, tz) + elif field == "summary": + data[model_field] = self.sanitize_fragment(value) + else: + data[model_field] = value - value = truncate_text(Post, model_field, entry[field]) + content_details = self.get_content_details(entry) - if field == "published_parsed": - data[model_field] = build_publication_date(value, tz) - elif field == "summary": - data[model_field] = self.sanitize_fragment(value) - else: - data[model_field] = value + # use content details key if it contains more information + if not "body" in data or len(data["body"]) < len(content_details): + data["body"] = content_details - if "content" in entry: - content = self.get_content(entry["content"]) - body = data.get("body", "") + return Post(**data) - if not body or len(body) < len(content): - data["body"] = content + def get_content_details(self, entry): + content_items = entry.get("content") - instances.append(Post(**data)) + if not content_items: + return "" - self.instances = duplicate_handler.check(instances) - - def get_content(self, items): - content = "\n ".join([item.get("value") for item in items]) - return self.sanitize_fragment(content) + content_details = "\n ".join([item.get("value") for item in content_items]) + return self.sanitize_fragment(content_details) class FeedStream(PostStream): diff --git a/src/newsreader/news/collection/reddit.py b/src/newsreader/news/collection/reddit.py index daeb85f..1fbffe2 100644 --- a/src/newsreader/news/collection/reddit.py +++ b/src/newsreader/news/collection/reddit.py @@ -28,6 +28,10 @@ from newsreader.news.collection.constants import ( WHITELISTED_TAGS, ) from newsreader.news.collection.exceptions import ( + BuilderDuplicateException, + BuilderException, + BuilderMissingDataException, + BuilderParseException, StreamDeniedException, StreamException, StreamParseException, @@ -122,99 +126,136 @@ class RedditBuilder(PostBuilder): if not "data" in self.payload or not "children" in self.payload["data"]: return - posts = self.payload["data"]["children"] - rule = self.stream.rule - - for post in posts: - if not "data" in post or post["kind"] != REDDIT_POST: - continue - - data = post["data"] - - remote_identifier = data["id"] - title = truncate_text(Post, "title", data["title"]) - author = truncate_text(Post, "author", data["author"]) - post_url_fragment = data["permalink"] - direct_url = data["url"] - is_text_post = data["is_self"] - - if remote_identifier in results: - continue - - if is_text_post: - uncleaned_body = data["selftext_html"] - unescaped_body = unescape(uncleaned_body) if uncleaned_body else "" - body = self.sanitize_fragment(unescaped_body) if unescaped_body else "" - elif direct_url.endswith(REDDIT_IMAGE_EXTENSIONS): - body = format_html( - "
      {title}
      ", - url=direct_url, - title=title, - ) - elif data["is_video"]: - video_info = data["secure_media"]["reddit_video"] - - body = format_html( - "
      ", - url=video_info["fallback_url"], - ) - elif direct_url.endswith(REDDIT_VIDEO_EXTENSIONS): - extension = next( - extension.replace(".", "") - for extension in REDDIT_VIDEO_EXTENSIONS - if direct_url.endswith(extension) - ) - - if extension == "gifv": - body = format_html( - "
      ", - url=direct_url.replace(extension, "mp4"), - ) - else: - body = format_html( - "
      ", - url=direct_url, - extension=extension, - ) - else: - body = format_html( - "", - url=direct_url, - title=title, - ) + entries = self.payload["data"]["children"] + for entry in entries: try: - parsed_date = datetime.fromtimestamp(post["data"]["created_utc"]) - created_date = pytz.utc.localize(parsed_date) - except (OverflowError, OSError): - logging.warning( - f"Failed parsing timestamp from {REDDIT_URL}{post_url_fragment}" - ) - created_date = timezone.now() - - post_data = { - "remote_identifier": remote_identifier, - "title": title, - "body": body, - "author": author, - "url": f"{REDDIT_URL}{post_url_fragment}", - "publication_date": created_date, - "rule": rule, - } - - if remote_identifier in self.existing_posts: - existing_post = self.existing_posts[remote_identifier] - - for key, value in post_data.items(): - setattr(existing_post, key, value) - - results[existing_post.remote_identifier] = existing_post + post = self.build_post(entry) + except BuilderException: + logger.exception("Failed building post") continue - results[remote_identifier] = Post(**post_data) + identifier = post.remote_identifier + results[identifier] = post self.instances = results.values() + def build_post(self, entry): + rule = self.stream.rule + entry_data = entry.get("data", {}) + remote_identifier = entry_data.get("id", "") + kind = entry.get("kind") + + if remote_identifier in self.existing_posts: + raise BuilderDuplicateException(payload=entry) + elif kind != REDDIT_POST: + raise BuilderParseException( + message=f"Payload is not an reddit post, its of kind {kind}", + payload=entry, + ) + elif not entry_data: + raise BuilderMissingDataException( + message=f"Post {remote_identifier} did not contain any data", + payload=entry, + ) + + try: + title = entry_data["title"] + author = entry_data["author"] + post_url_fragment = entry_data["permalink"] + direct_url = entry_data["url"] + is_text = entry_data["is_self"] + is_video = entry_data["is_video"] + except KeyError as e: + raise BuilderMissingDataException(payload=entry) from e + + title = truncate_text(Post, "title", title) + author = truncate_text(Post, "author", author) + + if is_text: + body = self.get_text_post(entry_data) + elif direct_url.endswith(REDDIT_IMAGE_EXTENSIONS): + body = self.get_image_post(title, direct_url) + elif is_video: + body = self.get_native_video_post(entry_data) + elif direct_url.endswith(REDDIT_VIDEO_EXTENSIONS): + body = self.get_video_post(direct_url) + else: + body = self.get_url_post(title, direct_url) + + try: + parsed_date = datetime.fromtimestamp(entry_data["created_utc"]) + created_date = pytz.utc.localize(parsed_date) + except (OverflowError, OSError) as e: + raise BuilderParseException(payload=entry) from e + except KeyError as e: + raise BuilderMissingDataException(payload=entry) from e + + post_entry = { + "remote_identifier": remote_identifier, + "title": title, + "body": body, + "author": author, + "url": f"{REDDIT_URL}{post_url_fragment}", + "publication_date": created_date, + "rule": rule, + } + + return Post(**post_entry) + + def get_text_post(self, entry): + try: + uncleaned_body = entry["selftext_html"] + except KeyError as e: + raise BuilderMissingDataException(payload=entry) from e + + unescaped_body = unescape(uncleaned_body) if uncleaned_body else "" + return self.sanitize_fragment(unescaped_body) if unescaped_body else "" + + def get_image_post(self, title, url): + return format_html( + "
      {title}
      ", + url=url, + title=title, + ) + + def get_native_video_post(self, entry): + try: + video_info = entry["secure_media"]["reddit_video"] + except KeyError as e: + raise BuilderMissingDataException(payload=entry) from e + + return format_html( + "
      ", + url=video_info["fallback_url"], + ) + + def get_video_post(self, url): + extension = next( + extension.replace(".", "") + for extension in REDDIT_VIDEO_EXTENSIONS + if url.endswith(extension) + ) + + if extension == "gifv": + return format_html( + "
      ", + url=url.replace(extension, "mp4"), + ) + + return format_html( + "
      ", + url=url, + extension=extension, + ) + + def get_url_post(self, title, url): + return format_html( + "", + url=url, + title=title, + ) + class RedditStream(PostStream): rule_type = RuleTypeChoices.subreddit diff --git a/src/newsreader/news/collection/tests/feed/builder/tests.py b/src/newsreader/news/collection/tests/feed/builder/tests.py index 571a7cd..7f4edf0 100644 --- a/src/newsreader/news/collection/tests/feed/builder/tests.py +++ b/src/newsreader/news/collection/tests/feed/builder/tests.py @@ -1,4 +1,4 @@ -from datetime import date, datetime, time +from datetime import datetime from unittest.mock import Mock from django.test import TestCase @@ -21,277 +21,233 @@ class FeedBuilderTestCase(TestCase): def setUp(self): self.maxDiff = None - def test_basic_entry(self): - builder = FeedBuilder - rule = FeedFactory() - mock_stream = Mock(rule=rule) - - with builder(simple_mock, mock_stream) as builder: - builder.build() - builder.save() - - post = Post.objects.get() - - publication_date = datetime.combine( - date(2019, 5, 20), time(hour=16, minute=7, second=37) - ) - aware_date = pytz.utc.localize(publication_date) - - self.assertEquals(post.publication_date, aware_date) - self.assertEquals(Post.objects.count(), 1) - - self.assertEquals( - post.remote_identifier, - "https://www.bbc.co.uk/news/world-us-canada-48338168", - ) - - self.assertEquals( - post.url, "https://www.bbc.co.uk/news/world-us-canada-48338168" - ) - - self.assertEquals( - post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif" - ) - def test_multiple_entries(self): - builder = FeedBuilder rule = FeedFactory() mock_stream = Mock(rule=rule) - with builder(multiple_mock, mock_stream) as builder: + with FeedBuilder(multiple_mock, mock_stream) as builder: builder.build() builder.save() posts = Post.objects.order_by("-publication_date") - self.assertEquals(Post.objects.count(), 3) + self.assertEqual(Post.objects.count(), 3) post = posts[0] - publication_date = datetime.combine( - date(2019, 5, 20), time(hour=16, minute=32, second=38) + publication_date = datetime( + 2019, 5, 20, hour=16, minute=32, second=38, tzinfo=pytz.utc ) - aware_date = pytz.utc.localize(publication_date) - self.assertEquals( + self.assertEqual( post.publication_date.strftime("%Y-%m-%d %H:%M:%S"), - aware_date.strftime("%Y-%m-%d %H:%M:%S"), + publication_date.strftime("%Y-%m-%d %H:%M:%S"), ) - self.assertEquals( + self.assertEqual( post.remote_identifier, "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", ) - self.assertEquals( + self.assertEqual( post.url, "https://www.bbc.co.uk/news/uk-england-birmingham-48339080" ) - self.assertEquals( + self.assertEqual( post.title, "Birmingham head teacher threatened over LGBT lessons" ) post = posts[1] - publication_date = datetime.combine( - date(2019, 5, 20), time(hour=16, minute=7, second=37) + publication_date = datetime( + 2019, 5, 20, hour=16, minute=7, second=37, tzinfo=pytz.utc ) - aware_date = pytz.utc.localize(publication_date) - self.assertEquals( + self.assertEqual( post.publication_date.strftime("%Y-%m-%d %H:%M:%S"), - aware_date.strftime("%Y-%m-%d %H:%M:%S"), + publication_date.strftime("%Y-%m-%d %H:%M:%S"), ) - self.assertEquals( + self.assertEqual( post.remote_identifier, "https://www.bbc.co.uk/news/world-us-canada-48338168", ) - self.assertEquals( + self.assertEqual( post.url, "https://www.bbc.co.uk/news/world-us-canada-48338168" ) - self.assertEquals( + self.assertEqual( post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif" ) def test_entries_without_remote_identifier(self): - builder = FeedBuilder rule = FeedFactory() mock_stream = Mock(rule=rule) - with builder(mock_without_identifier, mock_stream) as builder: + with FeedBuilder(mock_without_identifier, mock_stream) as builder: builder.build() builder.save() posts = Post.objects.order_by("-publication_date") - self.assertEquals(Post.objects.count(), 2) + self.assertEqual(Post.objects.count(), 2) post = posts[0] - publication_date = datetime.combine( - date(2019, 5, 20), time(hour=16, minute=7, second=37) + publication_date = datetime( + 2019, 5, 20, hour=16, minute=7, second=37, tzinfo=pytz.utc ) - aware_date = pytz.utc.localize(publication_date) - self.assertEquals(post.publication_date, aware_date) - self.assertEquals(post.remote_identifier, None) - self.assertEquals( + self.assertEqual(post.publication_date, publication_date) + self.assertEqual(post.remote_identifier, None) + self.assertEqual( post.url, "https://www.bbc.co.uk/news/world-us-canada-48338168" ) - self.assertEquals( + self.assertEqual( post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif" ) post = posts[1] - publication_date = datetime.combine( - date(2019, 5, 20), time(hour=12, minute=19, second=19) + publication_date = datetime( + 2019, 5, 20, hour=12, minute=19, second=19, tzinfo=pytz.utc ) - aware_date = pytz.utc.localize(publication_date) - self.assertEquals(post.publication_date, aware_date) - self.assertEquals(post.remote_identifier, None) - self.assertEquals(post.url, "https://www.bbc.co.uk/news/technology-48334739") - self.assertEquals(post.title, "Huawei's Android loss: How it affects you") + self.assertEqual(post.publication_date, publication_date) + self.assertEqual(post.remote_identifier, None) + self.assertEqual(post.url, "https://www.bbc.co.uk/news/technology-48334739") + self.assertEqual(post.title, "Huawei's Android loss: How it affects you") def test_entry_without_publication_date(self): - builder = FeedBuilder rule = FeedFactory() mock_stream = Mock(rule=rule) - with builder(mock_without_publish_date, mock_stream) as builder: + with FeedBuilder(mock_without_publish_date, mock_stream) as builder: builder.build() builder.save() posts = Post.objects.order_by("-publication_date") - self.assertEquals(Post.objects.count(), 2) + self.assertEqual(Post.objects.count(), 2) post = posts[0] - self.assertEquals( + self.assertEqual( post.publication_date.strftime("%Y-%m-%d %H:%M"), "2019-10-30 12:30" ) - self.assertEquals(post.created, timezone.now()) - self.assertEquals( + self.assertEqual(post.created, timezone.now()) + self.assertEqual( post.remote_identifier, "https://www.bbc.co.uk/news/world-us-canada-48338168", ) post = posts[1] - self.assertEquals( + self.assertEqual( post.publication_date.strftime("%Y-%m-%d %H:%M"), "2019-10-30 12:30" ) - self.assertEquals(post.created, timezone.now()) - self.assertEquals( + self.assertEqual(post.created, timezone.now()) + self.assertEqual( post.remote_identifier, "https://www.bbc.co.uk/news/technology-48334739" ) def test_entry_without_url(self): - builder = FeedBuilder rule = FeedFactory() mock_stream = Mock(rule=rule) - with builder(mock_without_url, mock_stream) as builder: + with FeedBuilder(mock_without_url, mock_stream) as builder: builder.build() builder.save() posts = Post.objects.order_by("-publication_date") - self.assertEquals(Post.objects.count(), 2) + self.assertEqual(Post.objects.count(), 2) post = posts[0] - self.assertEquals(post.created, timezone.now()) - self.assertEquals( + self.assertEqual(post.created, timezone.now()) + self.assertEqual( post.remote_identifier, "https://www.bbc.co.uk/news/world-us-canada-48338168", ) post = posts[1] - self.assertEquals(post.created, timezone.now()) - self.assertEquals( + self.assertEqual(post.created, timezone.now()) + self.assertEqual( post.remote_identifier, "https://www.bbc.co.uk/news/technology-48334739" ) def test_entry_without_body(self): - builder = FeedBuilder rule = FeedFactory() mock_stream = Mock(rule=rule) - with builder(mock_without_body, mock_stream) as builder: + with FeedBuilder(mock_without_body, mock_stream) as builder: builder.build() builder.save() posts = Post.objects.order_by("-publication_date") - self.assertEquals(Post.objects.count(), 2) + self.assertEqual(Post.objects.count(), 2) post = posts[0] - self.assertEquals( + self.assertEqual( post.created.strftime("%Y-%m-%d %H:%M:%S"), "2019-10-30 12:30:00" ) - self.assertEquals( + self.assertEqual( post.remote_identifier, "https://www.bbc.co.uk/news/uk-england-birmingham-48339080", ) - self.assertEquals(post.body, "") + self.assertEqual(post.body, "") post = posts[1] - self.assertEquals( + self.assertEqual( post.created.strftime("%Y-%m-%d %H:%M:%S"), "2019-10-30 12:30:00" ) - self.assertEquals( + self.assertEqual( post.remote_identifier, "https://www.bbc.co.uk/news/world-us-canada-48338168", ) - self.assertEquals(post.body, "") + self.assertEqual(post.body, "") def test_entry_without_author(self): - builder = FeedBuilder rule = FeedFactory() mock_stream = Mock(rule=rule) - with builder(mock_without_author, mock_stream) as builder: + with FeedBuilder(mock_without_author, mock_stream) as builder: builder.build() builder.save() posts = Post.objects.order_by("-publication_date") - self.assertEquals(Post.objects.count(), 2) + self.assertEqual(Post.objects.count(), 2) post = posts[0] - self.assertEquals(post.created, timezone.now()) - self.assertEquals( + self.assertEqual(post.created, timezone.now()) + self.assertEqual( post.remote_identifier, "https://www.bbc.co.uk/news/world-us-canada-48338168", ) - self.assertEquals(post.author, None) + self.assertEqual(post.author, None) post = posts[1] - self.assertEquals(post.created, timezone.now()) - self.assertEquals( + self.assertEqual(post.created, timezone.now()) + self.assertEqual( post.remote_identifier, "https://www.bbc.co.uk/news/technology-48334739" ) - self.assertEquals(post.author, None) + self.assertEqual(post.author, None) def test_empty_entries(self): - builder = FeedBuilder rule = FeedFactory() mock_stream = Mock(rule=rule) - with builder(mock_without_entries, mock_stream) as builder: + with FeedBuilder(mock_without_entries, mock_stream) as builder: builder.build() builder.save() - self.assertEquals(Post.objects.count(), 0) + self.assertEqual(Post.objects.count(), 0) def test_update_entries(self): - builder = FeedBuilder rule = FeedFactory() mock_stream = Mock(rule=rule) @@ -303,36 +259,35 @@ class FeedBuilderTestCase(TestCase): remote_identifier="a5479c66-8fae-11e9-8422-00163ef6bee7", rule=rule ) - with builder(mock_with_update_entries, mock_stream) as builder: + with FeedBuilder(mock_with_update_entries, mock_stream) as builder: builder.build() builder.save() - self.assertEquals(Post.objects.count(), 3) + self.assertEqual(Post.objects.count(), 3) existing_first_post.refresh_from_db() existing_second_post.refresh_from_db() - self.assertEquals( + self.assertEqual( existing_first_post.title, "Trump's 'genocidal taunts' will not end Iran - Zarif", ) - self.assertEquals( + self.assertEqual( existing_second_post.title, "Huawei's Android loss: How it affects you" ) def test_html_sanitizing(self): - builder = FeedBuilder rule = FeedFactory() mock_stream = Mock(rule=rule) - with builder(mock_with_html, mock_stream) as builder: + with FeedBuilder(mock_with_html, mock_stream) as builder: builder.build() builder.save() post = Post.objects.get() - self.assertEquals(Post.objects.count(), 1) + self.assertEqual(Post.objects.count(), 1) self.assertTrue("
      " in post.body) self.assertTrue("

      " in post.body) @@ -345,64 +300,60 @@ class FeedBuilderTestCase(TestCase): self.assertTrue("", - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "7127ec98-5859-11e8-9488-0e8717893ec8", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "#0aa18f", - "id": "hnd7cy", - "is_robot_indexable": True, - "report_reasons": None, - "author": "TheQuantumZero", - "discussion_type": None, - "num_comments": 120, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "crosspost_parent": "t3_hnd6yo", - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hnd7cy/board_statement_on_the_libreoffice_70_rc_personal/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", - "subreddit_subscribers": 544037, - "created_utc": 1594196218.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - } - ], - "after": "t3_hmytic", - "before": None, - }, -} - -author_mock = { - "kind": "Listing", - "data": { - "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", - "dist": 27, - "children": [ - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "", - "author_fullname": "t2_hlv0o", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', - "link_flair_richtext": [{"e": "text", "t": "Popular Application"}], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hnd7cy", - "quarantine": False, - "link_flair_text_color": "light", - "upvote_ratio": 0.95, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 226, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "Popular Application", - "can_mod_post": False, - "score": 226, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "crosspost_parent_list": [ - { - "approved_at_utc": None, - "subreddit": "libreoffice", - "selftext": "", - "author_fullname": "t2_hlv0o", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/libreoffice", - "hidden": False, - "pwls": 6, - "link_flair_css_class": "", - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hnd6yo", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.96, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 29, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "News", - "can_mod_post": False, - "score": 29, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594224961.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "blog.documentfoundation.org", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "dc82ac98-bafb-11e4-9f88-22000b310327", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2s4nt", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hnd6yo", - "is_robot_indexable": True, - "report_reasons": None, - "author": "TheQuantumZero", - "discussion_type": None, - "num_comments": 38, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/libreoffice/comments/hnd6yo/board_statement_on_the_libreoffice_70_rc_personal/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", - "subreddit_subscribers": 4669, - "created_utc": 1594196161.0, - "num_crossposts": 2, - "media": None, - "is_video": False, - } - ], - "created": 1594225018.0, - "link_flair_type": "richtext", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "blog.documentfoundation.org", - "allow_live_comments": False, - "selftext_html": "
      ", - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "7127ec98-5859-11e8-9488-0e8717893ec8", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "#0aa18f", - "id": "hnd7cy", - "is_robot_indexable": True, - "report_reasons": None, - "author": "TheQuantumZeroTheQuantumZeroTheQuantumZero", - "discussion_type": None, - "num_comments": 120, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "crosspost_parent": "t3_hnd6yo", - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hnd7cy/board_statement_on_the_libreoffice_70_rc_personal/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", - "subreddit_subscribers": 544037, - "created_utc": 1594196218.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - } - ], - "after": "t3_hmytic", - "before": None, - }, -} - -title_mock = { - "kind": "Listing", - "data": { - "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", - "dist": 27, - "children": [ - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "", - "author_fullname": "t2_hlv0o", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": 'Board statement on the LibreOffice 7.0 RC "Personal EditionBoard statement on the LibreOffice 7.0 RC "Personal Edition" label" labelBoard statement on the LibreOffice 7.0 RC "PersBoard statement on the LibreOffice 7.0 RC "Personal Edition" labelonal Edition" label', - "link_flair_richtext": [{"e": "text", "t": "Popular Application"}], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hnd7cy", - "quarantine": False, - "link_flair_text_color": "light", - "upvote_ratio": 0.95, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 226, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "Popular Application", - "can_mod_post": False, - "score": 226, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "crosspost_parent_list": [ - { - "approved_at_utc": None, - "subreddit": "libreoffice", - "selftext": "", - "author_fullname": "t2_hlv0o", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/libreoffice", - "hidden": False, - "pwls": 6, - "link_flair_css_class": "", - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hnd6yo", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.96, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 29, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "News", - "can_mod_post": False, - "score": 29, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594224961.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "blog.documentfoundation.org", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "dc82ac98-bafb-11e4-9f88-22000b310327", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2s4nt", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hnd6yo", - "is_robot_indexable": True, - "report_reasons": None, - "author": "TheQuantumZero", - "discussion_type": None, - "num_comments": 38, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/libreoffice/comments/hnd6yo/board_statement_on_the_libreoffice_70_rc_personal/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", - "subreddit_subscribers": 4669, - "created_utc": 1594196161.0, - "num_crossposts": 2, - "media": None, - "is_video": False, - } - ], - "created": 1594225018.0, - "link_flair_type": "richtext", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "blog.documentfoundation.org", - "allow_live_comments": False, - "selftext_html": "
      ", - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "7127ec98-5859-11e8-9488-0e8717893ec8", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "#0aa18f", - "id": "hnd7cy", - "is_robot_indexable": True, - "report_reasons": None, - "author": "TheQuantumZeroTheQuantumZeroTheQuantumZero", - "discussion_type": None, - "num_comments": 120, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "crosspost_parent": "t3_hnd6yo", - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hnd7cy/board_statement_on_the_libreoffice_70_rc_personal/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", - "subreddit_subscribers": 544037, - "created_utc": 1594196218.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - } - ], - "after": "t3_hmytic", - "before": None, - }, -} - -duplicate_mock = { - "kind": "Listing", - "data": { - "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", - "dist": 27, - "children": [ - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "Welcome to r/linux rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.\n\nLet us know what's annoying you, whats making you happy, or something that you want to get out to r/linux but didn't make the cut into a full post of it's own.\n\nFor those looking for certifications please use this megathread to ask about how to get certified whether it's for the business world or for your own satisfaction. Be sure to check out r/linuxadmin for more discussion in the SysAdmin world!\n\n_Please keep questions in r/linuxquestions, r/linux4noobs, or the Wednesday automod thread._", - "author_fullname": "t2_6l4z3", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hm0qct", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.7, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 8, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 8, - "approved_by": None, - "author_premium": True, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594037482.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.linux", - "allow_live_comments": False, - "selftext_html": "<!-- SC_OFF --><div class='md'><p>Welcome to <a href='/r/linux'>r/linux</a> rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.</p>\n\n<p>Let us know what&#39;s annoying you, whats making you happy, or something that you want to get out to <a href='/r/linux'>r/linux</a> but didn&#39;t make the cut into a full post of it&#39;s own.</p>\n\n<p>For those looking for certifications please use this megathread to ask about how to get certified whether it&#39;s for the business world or for your own satisfaction. Be sure to check out <a href='/r/linuxadmin'>r/linuxadmin</a> for more discussion in the SysAdmin world!</p>\n\n<p><em>Please keep questions in <a href='/r/linuxquestions'>r/linuxquestions</a>, <a href='/r/linux4noobs'>r/linux4noobs</a>, or the Wednesday automod thread.</em></p>\n</div><!-- SC_ON -->", - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": True, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": "moderator", - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hm0qct", - "is_robot_indexable": True, - "report_reasons": None, - "author": "AutoModerator", - "discussion_type": None, - "num_comments": 9, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", - "parent_whitelist_status": "all_ads", - "stickied": True, - "url": "https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", - "subreddit_subscribers": 544037, - "created_utc": 1594008682.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "Welcome to r/linux! If you're new to Linux or trying to get started this thread is for you. Get help here or as always, check out r/linuxquestions or r/linux4noobs\n\nThis megathread is for all your question needs. As we don't allow questions on r/linux outside of this megathread, please consider using r/linuxquestions or r/linux4noobs for the best solution to your problem.\n\nAsk your hardware requests here too or try r/linuxhardware!", - "author_fullname": "t2_6l4z3", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Weekly Questions and Hardware Thread - July 08, 2020", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hna75r", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.6, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 2, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 2, - "approved_by": None, - "author_premium": True, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594210138.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.linux", - "allow_live_comments": False, - "selftext_html": '<!-- SC_OFF --><div class="md"><p>Welcome to <a href="/r/linux">r/linux</a>! If you&#39;re new to Linux or trying to get started this thread is for you. Get help here or as always, check out <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a></p>\n\n<p>This megathread is for all your question needs. As we don&#39;t allow questions on <a href="/r/linux">r/linux</a> outside of this megathread, please consider using <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a> for the best solution to your problem.</p>\n\n<p>Ask your hardware requests here too or try <a href="/r/linuxhardware">r/linuxhardware</a>!</p>\n</div><!-- SC_ON -->', - "likes": None, - "suggested_sort": "new", - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": True, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": "moderator", - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hna75r", - "is_robot_indexable": True, - "report_reasons": None, - "author": "AutoModerator", - "discussion_type": None, - "num_comments": 2, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", - "parent_whitelist_status": "all_ads", - "stickied": True, - "url": "https://www.reddit.com/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", - "subreddit_subscribers": 544037, - "created_utc": 1594181338.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "Welcome to r/linux rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.\n\nLet us know what's annoying you, whats making you happy, or something that you want to get out to r/linux but didn't make the cut into a full post of it's own.\n\nFor those looking for certifications please use this megathread to ask about how to get certified whether it's for the business world or for your own satisfaction. Be sure to check out r/linuxadmin for more discussion in the SysAdmin world!\n\n_Please keep questions in r/linuxquestions, r/linux4noobs, or the Wednesday automod thread._", - "author_fullname": "t2_6l4z3", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hm0qct", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.7, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 8, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 8, - "approved_by": None, - "author_premium": True, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594037482.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.linux", - "allow_live_comments": False, - "selftext_html": "<!-- SC_OFF --><div class='md'><p>Welcome to <a href='/r/linux'>r/linux</a> rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.</p>\n\n<p>Let us know what&#39;s annoying you, whats making you happy, or something that you want to get out to <a href='/r/linux'>r/linux</a> but didn&#39;t make the cut into a full post of it&#39;s own.</p>\n\n<p>For those looking for certifications please use this megathread to ask about how to get certified whether it&#39;s for the business world or for your own satisfaction. Be sure to check out <a href='/r/linuxadmin'>r/linuxadmin</a> for more discussion in the SysAdmin world!</p>\n\n<p><em>Please keep questions in <a href='/r/linuxquestions'>r/linuxquestions</a>, <a href='/r/linux4noobs'>r/linux4noobs</a>, or the Wednesday automod thread.</em></p>\n</div><!-- SC_ON -->", - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": True, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": "moderator", - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hm0qct", - "is_robot_indexable": True, - "report_reasons": None, - "author": "AutoModerator", - "discussion_type": None, - "num_comments": 9, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", - "parent_whitelist_status": "all_ads", - "stickied": True, - "url": "https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", - "subreddit_subscribers": 544037, - "created_utc": 1594008682.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - ], - "after": "t3_hmytic", - "before": None, - }, -} - -image_mock = { - "data": { - "after": "t3_hr3mhe", - "before": None, - "children": [ - { - "data": { - "all_awardings": [], - "allow_live_comments": True, - "approved_at_utc": None, - "approved_by": None, - "archived": False, - "author": "SamLynn79", - "author_flair_background_color": None, - "author_flair_css_class": None, - "author_flair_richtext": [], - "author_flair_template_id": None, - "author_flair_text": None, - "author_flair_text_color": None, - "author_flair_type": "text", - "author_fullname": "t2_6c9cj", - "author_patreon_flair": False, - "author_premium": True, - "awarders": [], - "banned_at_utc": None, - "banned_by": None, - "can_gild": True, - "can_mod_post": False, - "category": None, - "clicked": False, - "content_categories": None, - "contest_mode": False, - "created": 1594777552.0, - "created_utc": 1594748752.0, - "discussion_type": None, - "distinguished": None, - "domain": "i.redd.it", - "downs": 0, - "edited": False, - "gilded": 1, - "gildings": {"gid_2": 1}, - "hidden": False, - "hide_score": False, - "id": "hr64xh", - "is_crosspostable": True, - "is_meta": False, - "is_original_content": False, - "is_reddit_media_domain": True, - "is_robot_indexable": True, - "is_self": False, - "is_video": False, - "likes": None, - "link_flair_background_color": "", - "link_flair_css_class": None, - "link_flair_richtext": [], - "link_flair_text": None, - "link_flair_text_color": "dark", - "link_flair_type": "text", - "locked": True, - "media": None, - "media_embed": {}, - "media_only": False, - "mod_note": None, - "mod_reason_by": None, - "mod_reason_title": None, - "mod_reports": [], - "name": "t3_hr64xh", - "no_follow": False, - "num_comments": 579, - "num_crossposts": 2, - "num_reports": None, - "over_18": False, - "parent_whitelist_status": "all_ads", - "permalink": "/r/aww/comments/hr64xh/yall_i_just_cant_this_is_my_son_judah_my_wife_and/", - "pinned": False, - "post_hint": "image", - "preview": { - "enabled": True, - "images": [ - { - "id": "xWBh4hObZx0zmG_IDOHBLNN-_NZzEss2dAgm1sm9p1w", - "resolutions": [ - { - "height": 135, - "url": "https://preview.redd.it/cm2qybia1va51.jpg?width=108&crop=smart&auto=webp&s=5374b8f3dff520eba8cf97b589ebc67206f130dc", - "width": 108, - }, - { - "height": 270, - "url": "https://preview.redd.it/cm2qybia1va51.jpg?width=216&crop=smart&auto=webp&s=09d937a8db6f843d9fd34ee024cdfc6432dc0a13", - "width": 216, - }, - { - "height": 400, - "url": "https://preview.redd.it/cm2qybia1va51.jpg?width=320&crop=smart&auto=webp&s=9ba3654c12cb54f6d9c2dce1b07c80ecd6ca9d06", - "width": 320, - }, - { - "height": 800, - "url": "https://preview.redd.it/cm2qybia1va51.jpg?width=640&crop=smart&auto=webp&s=8c53747ae0f92b65fdd41f3aab60ebb8f8d4b1ca", - "width": 640, - }, - { - "height": 1200, - "url": "https://preview.redd.it/cm2qybia1va51.jpg?width=960&crop=smart&auto=webp&s=5668a626da6cd69e23b6c01587783c6cc5817bea", - "width": 960, - }, - { - "height": 1350, - "url": "https://preview.redd.it/cm2qybia1va51.jpg?width=1080&crop=smart&auto=webp&s=8fdd61aed8718109f3739cb532d96be31192b9a0", - "width": 1080, - }, - ], - "source": { - "height": 1800, - "url": "https://preview.redd.it/cm2qybia1va51.jpg?auto=webp&s=17b817b8d0e35bddc7f605d242cd7d116ef8e235", - "width": 1440, - }, - "variants": {}, - } - ], - }, - "pwls": 6, - "quarantine": False, - "removal_reason": None, - "removed_by": None, - "removed_by_category": None, - "report_reasons": None, - "saved": False, - "score": 23419, - "secure_media": None, - "secure_media_embed": {}, - "selftext": "", - "selftext_html": None, - "send_replies": True, - "spoiler": False, - "stickied": False, - "subreddit": "aww", - "subreddit_id": "t5_2qh1o", - "subreddit_name_prefixed": "r/aww", - "subreddit_subscribers": 25634399, - "subreddit_type": "public", - "suggested_sort": None, - "thumbnail": "https://b.thumbs.redditmedia.com/0X39S2jBL66zQCUbJAtlRKeswI8uUxf3-7vmog0VLjc.jpg", - "thumbnail_height": 140, - "thumbnail_width": 140, - "title": "Ya’ll, I just can’t... this is my " - "son, Judah. My wife and I have no " - "idea how we created such a " - "beautiful child.", - "top_awarded_type": None, - "total_awards_received": 4, - "treatment_tags": [], - "ups": 23419, - "upvote_ratio": 0.72, - "url": "https://i.redd.it/cm2qybia1va51.jpg", - "url_overridden_by_dest": "https://i.redd.it/cm2qybia1va51.jpg", - "user_reports": [], - "view_count": None, - "visited": False, - "whitelist_status": "all_ads", - "wls": 6, - }, - "kind": "t3", - }, - { - "data": { - "all_awardings": [], - "allow_live_comments": True, - "approved_at_utc": None, - "approved_by": None, - "archived": False, - "author": "0_GG_0", - "author_flair_background_color": None, - "author_flair_css_class": None, - "author_flair_richtext": [], - "author_flair_template_id": None, - "author_flair_text": None, - "author_flair_text_color": None, - "author_flair_type": "text", - "author_fullname": "t2_70k94sn8", - "author_patreon_flair": False, - "author_premium": True, - "awarders": [], - "banned_at_utc": None, - "banned_by": None, - "can_gild": True, - "can_mod_post": False, - "category": None, - "clicked": False, - "content_categories": None, - "contest_mode": False, - "created": 1594771808.0, - "created_utc": 1594743008.0, - "discussion_type": None, - "distinguished": None, - "domain": "i.redd.it", - "downs": 0, - "edited": False, - "gilded": 0, - "gildings": {}, - "hidden": False, - "hide_score": False, - "id": "hr4bxo", - "is_crosspostable": True, - "is_meta": False, - "is_original_content": False, - "is_reddit_media_domain": True, - "is_robot_indexable": True, - "is_self": False, - "is_video": False, - "likes": None, - "link_flair_background_color": "", - "link_flair_css_class": "lc", - "link_flair_richtext": [], - "link_flair_text": None, - "link_flair_text_color": "dark", - "link_flair_type": "text", - "locked": False, - "media": None, - "media_embed": {}, - "media_only": False, - "mod_note": None, - "mod_reason_by": None, - "mod_reason_title": None, - "mod_reports": [], - "name": "t3_hr4bxo", - "no_follow": False, - "num_comments": 248, - "num_crossposts": 4, - "num_reports": None, - "over_18": False, - "parent_whitelist_status": "all_ads", - "permalink": "/r/aww/comments/hr4bxo/just_thought_yall_would_enjoy_my_goat_dressed_as/", - "pinned": False, - "post_hint": "image", - "preview": { - "enabled": True, - "images": [ - { - "id": "TSXyc6ZJGdCcHk7-wuWnJdVpqsa_t8hmVd4k_e3ofCA", - "resolutions": [ - { - "height": 144, - "url": "https://preview.redd.it/4udujbu6kua51.jpg?width=108&crop=smart&auto=webp&s=ed5a11a7637acc66de48e30fd51d5019fa0c69f7", - "width": 108, - }, - { - "height": 288, - "url": "https://preview.redd.it/4udujbu6kua51.jpg?width=216&crop=smart&auto=webp&s=a812bec268d8ea31dbb9dfe696e0798490538f5a", - "width": 216, - }, - { - "height": 426, - "url": "https://preview.redd.it/4udujbu6kua51.jpg?width=320&crop=smart&auto=webp&s=1be4e3bdea19243b0a627bacb4c9e04f2d3569a7", - "width": 320, - }, - { - "height": 853, - "url": "https://preview.redd.it/4udujbu6kua51.jpg?width=640&crop=smart&auto=webp&s=e73755c3f0b27bb0435d07aa60b32e091bed7957", - "width": 640, - }, - { - "height": 1280, - "url": "https://preview.redd.it/4udujbu6kua51.jpg?width=960&crop=smart&auto=webp&s=8ab6972fffc4786503284a0253e91e9104f2d01e", - "width": 960, - }, - { - "height": 1440, - "url": "https://preview.redd.it/4udujbu6kua51.jpg?width=1080&crop=smart&auto=webp&s=a1e554889179a7599786985679304fda706d83d6", - "width": 1080, - }, - ], - "source": { - "height": 4032, - "url": "https://preview.redd.it/4udujbu6kua51.jpg?auto=webp&s=3eefdef653e0a3a8a10090b804f0888ee6a1a163", - "width": 3024, - }, - "variants": {}, - } - ], - }, - "pwls": 6, - "quarantine": False, - "removal_reason": None, - "removed_by": None, - "removed_by_category": None, - "report_reasons": None, - "saved": False, - "score": 16684, - "secure_media": None, - "secure_media_embed": {}, - "selftext": "", - "selftext_html": None, - "send_replies": True, - "spoiler": False, - "stickied": False, - "subreddit": "aww", - "subreddit_id": "t5_2qh1o", - "subreddit_name_prefixed": "r/aww", - "subreddit_subscribers": 25634399, - "subreddit_type": "public", - "suggested_sort": None, - "thumbnail": "https://b.thumbs.redditmedia.com/h3Ylp4kb0uJzAsST4ZZGsGN8WGxK4wjK2XrM9uUH5uc.jpg", - "thumbnail_height": 140, - "thumbnail_width": 140, - "title": "Just thought y’all would enjoy my " - "goat dressed as a tractor", - "top_awarded_type": None, - "total_awards_received": 2, - "treatment_tags": [], - "ups": 16684, - "upvote_ratio": 0.98, - "url": "https://i.redd.it/4udujbu6kua51.jpg", - "url_overridden_by_dest": "https://i.redd.it/4udujbu6kua51.jpg", - "user_reports": [], - "view_count": None, - "visited": False, - "whitelist_status": "all_ads", - "wls": 6, - }, - "kind": "t3", - }, - { - "data": { - "all_awardings": [], - "allow_live_comments": True, - "approved_at_utc": None, - "approved_by": None, - "archived": False, - "author": "Mechanic619", - "author_flair_background_color": None, - "author_flair_css_class": None, - "author_flair_richtext": [], - "author_flair_template_id": None, - "author_flair_text": None, - "author_flair_text_color": None, - "author_flair_type": "text", - "author_fullname": "t2_4ptrdtz5", - "author_patreon_flair": False, - "author_premium": False, - "awarders": [], - "banned_at_utc": None, - "banned_by": None, - "can_gild": True, - "can_mod_post": False, - "category": None, - "clicked": False, - "content_categories": None, - "contest_mode": False, - "created": 1594760700.0, - "created_utc": 1594731900.0, - "discussion_type": None, - "distinguished": None, - "domain": "i.redd.it", - "downs": 0, - "edited": False, - "gilded": 0, - "gildings": {"gid_1": 1}, - "hidden": False, - "hide_score": False, - "id": "hr14y5", - "is_crosspostable": True, - "is_meta": False, - "is_original_content": False, - "is_reddit_media_domain": True, - "is_robot_indexable": True, - "is_self": False, - "is_video": False, - "likes": None, - "link_flair_background_color": "", - "link_flair_css_class": "lc", - "link_flair_richtext": [], - "link_flair_text": None, - "link_flair_text_color": "dark", - "link_flair_type": "text", - "locked": False, - "media": None, - "media_embed": {}, - "media_only": False, - "mod_note": None, - "mod_reason_by": None, - "mod_reason_title": None, - "mod_reports": [], - "name": "t3_hr14y5", - "no_follow": False, - "num_comments": 1439, - "num_crossposts": 20, - "num_reports": None, - "over_18": False, - "parent_whitelist_status": "all_ads", - "permalink": "/r/aww/comments/hr14y5/mosque_security_on_patrol/", - "pinned": False, - "post_hint": "image", - "preview": { - "enabled": True, - "images": [ - { - "id": "Qs_FmhJgYT8GWyxmDQ8kjBCs_w2V_77cvHvdqLJ7i4s", - "resolutions": [ - { - "height": 135, - "url": "https://preview.redd.it/jk08ge66nta51.jpg?width=108&crop=smart&auto=webp&s=cf4c24ef4f9be86d186c143296bd1e14f15f960a", - "width": 108, - }, - { - "height": 270, - "url": "https://preview.redd.it/jk08ge66nta51.jpg?width=216&crop=smart&auto=webp&s=308e2367a849334c32b579265ed738d9937bed71", - "width": 216, - }, - { - "height": 400, - "url": "https://preview.redd.it/jk08ge66nta51.jpg?width=320&crop=smart&auto=webp&s=bc890f054dc34bb3f8607a70d088926afe113ff1", - "width": 320, - }, - { - "height": 800, - "url": "https://preview.redd.it/jk08ge66nta51.jpg?width=640&crop=smart&auto=webp&s=e23a9bc2d8d1ac6ccefab7f30cfa9def741aaa25", - "width": 640, - }, - { - "height": 1201, - "url": "https://preview.redd.it/jk08ge66nta51.jpg?width=960&crop=smart&auto=webp&s=4d294d1626046d27edc2a281c21ab10502b9ca4c", - "width": 960, - }, - { - "height": 1351, - "url": "https://preview.redd.it/jk08ge66nta51.jpg?width=1080&crop=smart&auto=webp&s=a801e5d9d703204e8b1497d3038d6405b2ed1157", - "width": 1080, - }, - ], - "source": { - "height": 1413, - "url": "https://preview.redd.it/jk08ge66nta51.jpg?auto=webp&s=f4e87e2ad0f0e40ca4f7a08c2a894b234601f3ce", - "width": 1129, - }, - "variants": {}, - } - ], - }, - "pwls": 6, - "quarantine": False, - "removal_reason": None, - "removed_by": None, - "removed_by_category": None, - "report_reasons": None, - "saved": False, - "score": 89133, - "secure_media": None, - "secure_media_embed": {}, - "selftext": "", - "selftext_html": None, - "send_replies": True, - "spoiler": False, - "stickied": False, - "subreddit": "aww", - "subreddit_id": "t5_2qh1o", - "subreddit_name_prefixed": "r/aww", - "subreddit_subscribers": 25634399, - "subreddit_type": "public", - "suggested_sort": None, - "thumbnail": "https://b.thumbs.redditmedia.com/GGHjIElMHDgefR0UdMXVk8CHeDUBhuZMY_QHjls4ynA.jpg", - "thumbnail_height": 140, - "thumbnail_width": 140, - "title": "Mosque security on patrol", - "top_awarded_type": None, - "total_awards_received": 3, - "treatment_tags": [], - "ups": 89133, - "upvote_ratio": 0.93, - "url": "https://i.redd.it/jk08ge66nta51.jpg", - "url_overridden_by_dest": "https://i.redd.it/jk08ge66nta51.jpg", - "user_reports": [], - "view_count": None, - "visited": False, - "whitelist_status": "all_ads", - "wls": 6, - }, - "kind": "t3", - }, - { - "data": { - "all_awardings": [], - "allow_live_comments": False, - "approved_at_utc": None, - "approved_by": None, - "archived": False, - "author": "Amnesia19", - "author_cakeday": True, - "author_flair_background_color": None, - "author_flair_css_class": None, - "author_flair_richtext": [], - "author_flair_template_id": None, - "author_flair_text": None, - "author_flair_text_color": None, - "author_flair_type": "text", - "author_fullname": "t2_1rqe7gk1", - "author_patreon_flair": False, - "author_premium": False, - "awarders": [], - "banned_at_utc": None, - "banned_by": None, - "can_gild": True, - "can_mod_post": False, - "category": None, - "clicked": False, - "content_categories": None, - "contest_mode": False, - "created": 1594765470.0, - "created_utc": 1594736670.0, - "discussion_type": None, - "distinguished": None, - "domain": "i.redd.it", - "downs": 0, - "edited": False, - "gilded": 0, - "gildings": {}, - "hidden": False, - "hide_score": False, - "id": "hr2fv0", - "is_crosspostable": True, - "is_meta": False, - "is_original_content": False, - "is_reddit_media_domain": True, - "is_robot_indexable": True, - "is_self": False, - "is_video": False, - "likes": None, - "link_flair_background_color": "", - "link_flair_css_class": None, - "link_flair_richtext": [], - "link_flair_text": None, - "link_flair_text_color": "dark", - "link_flair_type": "text", - "locked": False, - "media": None, - "media_embed": {}, - "media_only": False, - "mod_note": None, - "mod_reason_by": None, - "mod_reason_title": None, - "mod_reports": [], - "name": "t3_hr2fv0", - "no_follow": False, - "num_comments": 71, - "num_crossposts": 1, - "num_reports": None, - "over_18": False, - "parent_whitelist_status": "all_ads", - "permalink": "/r/aww/comments/hr2fv0/the_look_my_dog_gives_my_grandpa/", - "pinned": False, - "post_hint": "image", - "preview": { - "enabled": True, - "images": [ - { - "id": "v0BbkKy6haXmUxmHz4oXygoR0E-cHkvZDACWL_s7STw", - "resolutions": [ - { - "height": 144, - "url": "https://preview.redd.it/y6q7bgzc1ua51.jpg?width=108&crop=smart&auto=webp&s=4e65e8ff55c02de0ebe79763c91fe43f51216717", - "width": 108, - }, - { - "height": 288, - "url": "https://preview.redd.it/y6q7bgzc1ua51.jpg?width=216&crop=smart&auto=webp&s=e2006e5fe7ac43f911c17dc7f185f33db24e3b52", - "width": 216, - }, - { - "height": 426, - "url": "https://preview.redd.it/y6q7bgzc1ua51.jpg?width=320&crop=smart&auto=webp&s=3dad39d5e48a1b176f7e87b2dd110fb0044b32d7", - "width": 320, - }, - { - "height": 853, - "url": "https://preview.redd.it/y6q7bgzc1ua51.jpg?width=640&crop=smart&auto=webp&s=2f8e86a3feca27a23a72d10b92aba1b79b80f7be", - "width": 640, - }, - { - "height": 1280, - "url": "https://preview.redd.it/y6q7bgzc1ua51.jpg?width=960&crop=smart&auto=webp&s=5ecdd44b728031f8e109f41f99841a1d6c8e86c8", - "width": 960, - }, - { - "height": 1440, - "url": "https://preview.redd.it/y6q7bgzc1ua51.jpg?width=1080&crop=smart&auto=webp&s=49555499040c0ac9958dabd98cbe4e90c054b2a7", - "width": 1080, - }, - ], - "source": { - "height": 4032, - "url": "https://preview.redd.it/y6q7bgzc1ua51.jpg?auto=webp&s=443e98e46a8a096e426ebdc256c45682f46ebe2a", - "width": 3024, - }, - "variants": {}, - } - ], - }, - "pwls": 6, - "quarantine": False, - "removal_reason": None, - "removed_by": None, - "removed_by_category": None, - "report_reasons": None, - "saved": False, - "score": 13614, - "secure_media": None, - "secure_media_embed": {}, - "selftext": "", - "selftext_html": None, - "send_replies": True, - "spoiler": False, - "stickied": False, - "subreddit": "aww", - "subreddit_id": "t5_2qh1o", - "subreddit_name_prefixed": "r/aww", - "subreddit_subscribers": 25634399, - "subreddit_type": "public", - "suggested_sort": None, - "thumbnail": "https://b.thumbs.redditmedia.com/RWRuGJ7ZyBtjO6alY1vbc65TQzgng8RFRWnPG7WUkhE.jpg", - "thumbnail_height": 140, - "thumbnail_width": 140, - "title": "The look my dog gives my grandpa", - "top_awarded_type": None, - "total_awards_received": 0, - "treatment_tags": [], - "ups": 13614, - "upvote_ratio": 0.99, - "url": "https://i.redd.it/y6q7bgzc1ua51.jpg", - "url_overridden_by_dest": "https://i.redd.it/y6q7bgzc1ua51.jpg", - "user_reports": [], - "view_count": None, - "visited": False, - "whitelist_status": "all_ads", - "wls": 6, - }, - "kind": "t3", - }, - ], - "dist": 25, - "modhash": None, - }, - "kind": "Listing", -} - -external_image_mock = { - "data": { - "after": "t3_hr3mhe", - "before": None, - "children": [ - { - "data": { - "all_awardings": [], - "allow_live_comments": False, - "approved_at_utc": None, - "approved_by": None, - "archived": False, - "author": "Captainbuttsreads", - "author_flair_background_color": None, - "author_flair_css_class": None, - "author_flair_richtext": [], - "author_flair_template_id": None, - "author_flair_text": None, - "author_flair_text_color": None, - "author_flair_type": "text", - "author_fullname": "t2_5qaat4af", - "author_patreon_flair": False, - "author_premium": False, - "awarders": [], - "banned_at_utc": None, - "banned_by": None, - "can_gild": True, - "can_mod_post": False, - "category": None, - "clicked": False, - "content_categories": None, - "contest_mode": False, - "created": 1594770844.0, - "created_utc": 1594742044.0, - "crosspost_parent": "t3_gc6eq2", - "crosspost_parent_list": [], - "discussion_type": None, - "distinguished": None, - "domain": "gfycat.com", - "downs": 0, - "edited": False, - "gilded": 0, - "gildings": {}, - "hidden": False, - "hide_score": False, - "id": "hr41am", - "is_crosspostable": True, - "is_meta": False, - "is_original_content": False, - "is_reddit_media_domain": False, - "is_robot_indexable": True, - "is_self": False, - "is_video": False, - "likes": None, - "link_flair_background_color": "", - "link_flair_css_class": None, - "link_flair_richtext": [], - "link_flair_text": None, - "link_flair_text_color": "dark", - "link_flair_type": "text", - "locked": False, - "media": None, - "media_embed": {}, - "media_only": False, - "mod_note": None, - "mod_reason_by": None, - "mod_reason_title": None, - "mod_reports": [], - "name": "t3_hr41am", - "no_follow": False, - "num_comments": 45, - "num_crossposts": 0, - "num_reports": None, - "over_18": False, - "parent_whitelist_status": "all_ads", - "permalink": "/r/aww/comments/hr41am/excited_cows_have_a_new_brush/", - "pinned": False, - "post_hint": "link", - "preview": { - "enabled": False, - "images": [ - { - "id": "l5tVSe6B4QDc7wk6Z9WfCXr20D_rAOHerf6i0N53nNc", - "resolutions": [ - { - "height": 108, - "url": "https://external-preview.redd.it/R51JAzaGbva91vYxn9uWL3NwCzWJW5mrdVxb1idjtBg.jpg?width=108&crop=smart&auto=webp&s=f908e1fb9403194a31f9a0c1f056f59e0718201e", - "width": 108, - }, - { - "height": 216, - "url": "https://external-preview.redd.it/R51JAzaGbva91vYxn9uWL3NwCzWJW5mrdVxb1idjtBg.jpg?width=216&crop=smart&auto=webp&s=de377df68832a52419d83c06ea74a13de28b96e0", - "width": 216, - }, - ], - "source": { - "height": 250, - "url": "https://external-preview.redd.it/R51JAzaGbva91vYxn9uWL3NwCzWJW5mrdVxb1idjtBg.jpg?auto=webp&s=b4166cb5a350e6d0197381cdf8db702f8a760493", - "width": 250, - }, - "variants": {}, - } - ], - "reddit_video_preview": { - "dash_url": "https://v.redd.it/mimyo7z6ppa51/DASHPlaylist.mpd", - "duration": 33, - "fallback_url": "https://v.redd.it/mimyo7z6ppa51/DASH_480.mp4", - "height": 640, - "hls_url": "https://v.redd.it/mimyo7z6ppa51/HLSPlaylist.m3u8", - "is_gif": True, - "scrubber_media_url": "https://v.redd.it/mimyo7z6ppa51/DASH_96.mp4", - "transcoding_status": "completed", - "width": 640, - }, - }, - "pwls": 6, - "quarantine": False, - "removal_reason": None, - "removed_by": None, - "removed_by_category": None, - "report_reasons": None, - "saved": False, - "score": 3219, - "secure_media": None, - "secure_media_embed": {}, - "selftext": "", - "selftext_html": None, - "send_replies": False, - "spoiler": False, - "stickied": False, - "subreddit": "aww", - "subreddit_id": "t5_2qh1o", - "subreddit_name_prefixed": "r/aww", - "subreddit_subscribers": 25634399, - "subreddit_type": "public", - "suggested_sort": None, - "thumbnail": "https://b.thumbs.redditmedia.com/NKTwvIU2xxoOMpzYNlYYstS2586x64Gi--52N0M-OJY.jpg", - "thumbnail_height": 140, - "thumbnail_width": 140, - "title": "Excited cows have a new brush!", - "top_awarded_type": None, - "total_awards_received": 0, - "treatment_tags": [], - "ups": 3219, - "upvote_ratio": 0.99, - "url": "http://gfycat.com/thatalivedogwoodclubgall", - "url_overridden_by_dest": "http://gfycat.com/thatalivedogwoodclubgall", - "user_reports": [], - "view_count": None, - "visited": False, - "whitelist_status": "all_ads", - "wls": 6, - }, - "kind": "t3", - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "aww", - "selftext": "", - "author_fullname": "t2_78ni2", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Novosibirsk Zoo welcomes 16 cobalt-eyed Pallas’s cat kittens", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/aww", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "thumbnail_height": 93, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_huoldn", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.99, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 1933, - "total_awards_received": 0, - "media_embed": {}, - "thumbnail_width": 140, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 1933, - "approved_by": None, - "author_premium": False, - "thumbnail": "https://a.thumbs.redditmedia.com/j-D-Z79QQ6tGk0E3SGdb8GzqbLVUY3lu59tDaXbOYl8.jpg", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "post_hint": "image", - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1595292144, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "i.imgur.com", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://i.imgur.com/usfMVUJ.jpg", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": False, - "pinned": False, - "over_18": False, - "preview": { - "images": [ - { - "source": { - "url": "https://external-preview.redd.it/iPfDCL-dfB4_TmnGsMEXWJMzxXmBEMPuV5ejLkYBNVo.jpg?auto=webp&s=2126d34a0134efa94ecab03917944709c8bc3305", - "width": 1024, - "height": 682, - }, - "resolutions": [ - { - "url": "https://external-preview.redd.it/iPfDCL-dfB4_TmnGsMEXWJMzxXmBEMPuV5ejLkYBNVo.jpg?width=108&crop=smart&auto=webp&s=710a44f787b98a0a37ca543b7428917ee55b3c46", - "width": 108, - "height": 71, - }, - { - "url": "https://external-preview.redd.it/iPfDCL-dfB4_TmnGsMEXWJMzxXmBEMPuV5ejLkYBNVo.jpg?width=216&crop=smart&auto=webp&s=b1bcdd7734a3a569f99fa88c6be9447105e58276", - "width": 216, - "height": 143, - }, - { - "url": "https://external-preview.redd.it/iPfDCL-dfB4_TmnGsMEXWJMzxXmBEMPuV5ejLkYBNVo.jpg?width=320&crop=smart&auto=webp&s=1671bf09a7b73d0ca51cf2de884b37d6a3591d6a", - "width": 320, - "height": 213, - }, - { - "url": "https://external-preview.redd.it/iPfDCL-dfB4_TmnGsMEXWJMzxXmBEMPuV5ejLkYBNVo.jpg?width=640&crop=smart&auto=webp&s=9fcdddbaeaad13273e0b53a862c73c4fee9f7e3d", - "width": 640, - "height": 426, - }, - { - "url": "https://external-preview.redd.it/iPfDCL-dfB4_TmnGsMEXWJMzxXmBEMPuV5ejLkYBNVo.jpg?width=960&crop=smart&auto=webp&s=e531480236c0ae72b78f27dd88f2cedc9f73cccc", - "width": 960, - "height": 639, - }, - ], - "variants": {}, - "id": "oJ9pHVA-JhoodtgNlku8ZQv8FhtadS2r36wGLAriUtY", - } - ], - "enabled": True, - }, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": False, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1o", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "huoldn", - "is_robot_indexable": True, - "report_reasons": None, - "author": "Ben_zyl", - "discussion_type": None, - "num_comments": 20, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/aww/comments/huoldn/novosibirsk_zoo_welcomes_16_cobalteyed_pallass/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://i.imgur.com/usfMVUJ.jpg", - "subreddit_subscribers": 25723833, - "created_utc": 1595263344, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - ], - "dist": 25, - "modhash": None, - }, - "kind": "Listing", -} - -video_mock = { - "data": { - "after": "t3_hr3mhe", - "before": None, - "children": [ - { - "data": { - "all_awardings": [], - "allow_live_comments": False, - "approved_at_utc": None, - "approved_by": None, - "archived": False, - "author": "TommyLondoner", - "author_flair_background_color": None, - "author_flair_css_class": None, - "author_flair_richtext": [], - "author_flair_template_id": None, - "author_flair_text": None, - "author_flair_text_color": None, - "author_flair_type": "text", - "author_fullname": "t2_75bis9gi", - "author_patreon_flair": False, - "author_premium": True, - "awarders": [], - "banned_at_utc": None, - "banned_by": None, - "can_gild": True, - "can_mod_post": False, - "category": None, - "clicked": False, - "content_categories": None, - "contest_mode": False, - "created": 1594767660.0, - "created_utc": 1594738860.0, - "discussion_type": None, - "distinguished": None, - "domain": "v.redd.it", - "downs": 0, - "edited": False, - "gilded": 1, - "gildings": {"gid_2": 1}, - "hidden": False, - "hide_score": False, - "id": "hr32jf", - "is_crosspostable": True, - "is_meta": False, - "is_original_content": False, - "is_reddit_media_domain": True, - "is_robot_indexable": True, - "is_self": False, - "is_video": True, - "likes": None, - "link_flair_background_color": "", - "link_flair_css_class": "lc", - "link_flair_richtext": [], - "link_flair_text": None, - "link_flair_text_color": "dark", - "link_flair_type": "text", - "locked": False, - "media": { - "reddit_video": { - "dash_url": "https://v.redd.it/9avhmd5s7ua51/DASHPlaylist.mpd?a=1597351258%2CODVjMjcyMDkzOWE1NDBiNzUwNzVhNDUwYmE0MGNiNzk5MGRmZmZmMzBhZjIzNDAzYzczY2NkNzRjNTgyMjAzNQ%3D%3D&v=1&f=sd", - "duration": 78, - "fallback_url": "https://v.redd.it/9avhmd5s7ua51/DASH_360.mp4?source=fallback", - "height": 428, - "hls_url": "https://v.redd.it/9avhmd5s7ua51/HLSPlaylist.m3u8?a=1597351258%2CNjE4YTA0NjUwZWNmNjhjNTRhNmU4ZjBmNDMyYWYxOGYzZTNkZWM2YjViM2I2ZDZjZWNhYzY0ZGVmOWU0Y2EyYg%3D%3D&v=1&f=sd", - "is_gif": False, - "scrubber_media_url": "https://v.redd.it/9avhmd5s7ua51/DASH_96.mp4", - "transcoding_status": "completed", - "width": 258, - } - }, - "media_embed": {}, - "media_only": False, - "mod_note": None, - "mod_reason_by": None, - "mod_reason_title": None, - "mod_reports": [], - "name": "t3_hr32jf", - "no_follow": False, - "num_comments": 150, - "num_crossposts": 2, - "num_reports": None, - "over_18": False, - "parent_whitelist_status": "all_ads", - "permalink": "/r/aww/comments/hr32jf/this_guy_definitely_loves_his_job/", - "pinned": False, - "post_hint": "hosted:video", - "preview": { - "enabled": False, - "images": [ - { - "id": "dX_mx_ZfJMwVn_pak9ZPQq8rMT_gPkW0_4gOzDxPSHM", - "resolutions": [ - { - "height": 179, - "url": "https://external-preview.redd.it/PMy-Z___DIG6aWnoyEy1VKottxLQFWCRSdHDV1a9N8w.png?width=108&crop=smart&format=pjpg&auto=webp&s=e0b8b68a78a8e9071bf56417ac6589bc8aff7634", - "width": 108, - }, - { - "height": 358, - "url": "https://external-preview.redd.it/PMy-Z___DIG6aWnoyEy1VKottxLQFWCRSdHDV1a9N8w.png?width=216&crop=smart&format=pjpg&auto=webp&s=8668c3c7ccbdacfe3376d8af4b1b49df9d6aec97", - "width": 216, - }, - ], - "source": { - "height": 428, - "url": "https://external-preview.redd.it/PMy-Z___DIG6aWnoyEy1VKottxLQFWCRSdHDV1a9N8w.png?format=pjpg&auto=webp&s=b0b6439fbe01c3f5d1bf1eae54a588cc745d3415", - "width": 258, - }, - "variants": {}, - } - ], - }, - "pwls": 6, - "quarantine": False, - "removal_reason": None, - "removed_by": None, - "removed_by_category": None, - "report_reasons": None, - "saved": False, - "score": 9324, - "secure_media": { - "reddit_video": { - "dash_url": "https://v.redd.it/9avhmd5s7ua51/DASHPlaylist.mpd?a=1597351258%2CODVjMjcyMDkzOWE1NDBiNzUwNzVhNDUwYmE0MGNiNzk5MGRmZmZmMzBhZjIzNDAzYzczY2NkNzRjNTgyMjAzNQ%3D%3D&v=1&f=sd", - "duration": 78, - "fallback_url": "https://v.redd.it/9avhmd5s7ua51/DASH_360.mp4?source=fallback", - "height": 428, - "hls_url": "https://v.redd.it/9avhmd5s7ua51/HLSPlaylist.m3u8?a=1597351258%2CNjE4YTA0NjUwZWNmNjhjNTRhNmU4ZjBmNDMyYWYxOGYzZTNkZWM2YjViM2I2ZDZjZWNhYzY0ZGVmOWU0Y2EyYg%3D%3D&v=1&f=sd", - "is_gif": False, - "scrubber_media_url": "https://v.redd.it/9avhmd5s7ua51/DASH_96.mp4", - "transcoding_status": "completed", - "width": 258, - } - }, - "secure_media_embed": {}, - "selftext": "", - "selftext_html": None, - "send_replies": True, - "spoiler": False, - "stickied": False, - "subreddit": "aww", - "subreddit_id": "t5_2qh1o", - "subreddit_name_prefixed": "r/aww", - "subreddit_subscribers": 25634399, - "subreddit_type": "public", - "suggested_sort": None, - "thumbnail": "https://b.thumbs.redditmedia.com/ibsS3H5xMLDSVglh8NBYJ4cgIsXuqYVLJWbiYVTykXg.jpg", - "thumbnail_height": 140, - "thumbnail_width": 140, - "title": "This guy definitely loves his job !", - "top_awarded_type": None, - "total_awards_received": 1, - "treatment_tags": [], - "ups": 9324, - "upvote_ratio": 0.96, - "url": "https://v.redd.it/9avhmd5s7ua51", - "url_overridden_by_dest": "https://v.redd.it/9avhmd5s7ua51", - "user_reports": [], - "view_count": None, - "visited": False, - "whitelist_status": "all_ads", - "wls": 6, - }, - "kind": "t3", - }, - { - "data": { - "all_awardings": [], - "allow_live_comments": True, - "approved_at_utc": None, - "approved_by": None, - "archived": False, - "author": "LucileEsparza", - "author_flair_background_color": None, - "author_flair_css_class": None, - "author_flair_richtext": [], - "author_flair_template_id": None, - "author_flair_text": None, - "author_flair_text_color": None, - "author_flair_type": "text", - "author_fullname": "t2_5loa1v96", - "author_patreon_flair": False, - "author_premium": False, - "awarders": [], - "banned_at_utc": None, - "banned_by": None, - "can_gild": True, - "can_mod_post": False, - "category": None, - "clicked": False, - "content_categories": None, - "contest_mode": False, - "created": 1594762969.0, - "created_utc": 1594734169.0, - "discussion_type": None, - "distinguished": None, - "domain": "v.redd.it", - "downs": 0, - "edited": False, - "gilded": 0, - "gildings": {}, - "hidden": False, - "hide_score": False, - "id": "hr1r00", - "is_crosspostable": True, - "is_meta": False, - "is_original_content": False, - "is_reddit_media_domain": True, - "is_robot_indexable": True, - "is_self": False, - "is_video": True, - "likes": None, - "link_flair_background_color": "", - "link_flair_css_class": "lc", - "link_flair_richtext": [], - "link_flair_text": None, - "link_flair_text_color": "dark", - "link_flair_type": "text", - "locked": False, - "media": { - "reddit_video": { - "dash_url": "https://v.redd.it/eyvbxaeqtta51/DASHPlaylist.mpd?a=1597351258%2CYjJmMWE3ZGJmM2FhMzVkYzZlNjIzOTAwM2ZmZTBkYjAxMzE0NDY2MDIyNGRhOWViMTViZTE0NTlmMzkzM2JlYg%3D%3D&v=1&f=sd", - "duration": 8, - "fallback_url": "https://v.redd.it/eyvbxaeqtta51/DASH_480.mp4?source=fallback", - "height": 640, - "hls_url": "https://v.redd.it/eyvbxaeqtta51/HLSPlaylist.m3u8?a=1597351258%2CY2JiMmQ0MjliNmE5NTA5MDE3YjAyNmVkYTg2Yjg1YWYwYmJlNDE4ZGM1NjE4ZDU3YjkzYjJlMDE2ZmM4Yzk5MQ%3D%3D&v=1&f=sd", - "is_gif": False, - "scrubber_media_url": "https://v.redd.it/eyvbxaeqtta51/DASH_96.mp4", - "transcoding_status": "completed", - "width": 640, - } - }, - "media_embed": {}, - "media_only": False, - "mod_note": None, - "mod_reason_by": None, - "mod_reason_title": None, - "mod_reports": [], - "name": "t3_hr1r00", - "no_follow": False, - "num_comments": 63, - "num_crossposts": 3, - "num_reports": None, - "over_18": False, - "parent_whitelist_status": "all_ads", - "permalink": "/r/aww/comments/hr1r00/cool_catt_and_his_clingy_girlfriend/", - "pinned": False, - "post_hint": "hosted:video", - "preview": { - "enabled": False, - "images": [ - { - "id": "wrscJ_l9A6Q_Mn1NAg06I4o3W39bbNgTBYg2Xm_Vl8U", - "resolutions": [ - { - "height": 108, - "url": "https://external-preview.redd.it/GkeXMqmDn03NOYgnIk0QDQAS8HfRacPrmYvyTUSv6w0.png?width=108&crop=smart&format=pjpg&auto=webp&s=f285ef95065be8a340e1cb7792d80a9640564eb6", - "width": 108, - }, - { - "height": 216, - "url": "https://external-preview.redd.it/GkeXMqmDn03NOYgnIk0QDQAS8HfRacPrmYvyTUSv6w0.png?width=216&crop=smart&format=pjpg&auto=webp&s=6d26b4f8d7b16f0f02bc6ce6f35af889b43cf026", - "width": 216, - }, - { - "height": 320, - "url": "https://external-preview.redd.it/GkeXMqmDn03NOYgnIk0QDQAS8HfRacPrmYvyTUSv6w0.png?width=320&crop=smart&format=pjpg&auto=webp&s=5d081467da187bd8c24e9c524583513ee6afe388", - "width": 320, - }, - { - "height": 640, - "url": "https://external-preview.redd.it/GkeXMqmDn03NOYgnIk0QDQAS8HfRacPrmYvyTUSv6w0.png?width=640&crop=smart&format=pjpg&auto=webp&s=557369f302f18b35284ffaacaccf09986f755187", - "width": 640, - }, - ], - "source": { - "height": 640, - "url": "https://external-preview.redd.it/GkeXMqmDn03NOYgnIk0QDQAS8HfRacPrmYvyTUSv6w0.png?format=pjpg&auto=webp&s=cb0a79a2effe0323e862fb713dab76b39051afbb", - "width": 640, - }, - "variants": {}, - } - ], - }, - "pwls": 6, - "quarantine": False, - "removal_reason": None, - "removed_by": None, - "removed_by_category": None, - "report_reasons": None, - "saved": False, - "score": 11007, - "secure_media": { - "reddit_video": { - "dash_url": "https://v.redd.it/eyvbxaeqtta51/DASHPlaylist.mpd?a=1597351258%2CYjJmMWE3ZGJmM2FhMzVkYzZlNjIzOTAwM2ZmZTBkYjAxMzE0NDY2MDIyNGRhOWViMTViZTE0NTlmMzkzM2JlYg%3D%3D&v=1&f=sd", - "duration": 8, - "fallback_url": "https://v.redd.it/eyvbxaeqtta51/DASH_480.mp4?source=fallback", - "height": 640, - "hls_url": "https://v.redd.it/eyvbxaeqtta51/HLSPlaylist.m3u8?a=1597351258%2CY2JiMmQ0MjliNmE5NTA5MDE3YjAyNmVkYTg2Yjg1YWYwYmJlNDE4ZGM1NjE4ZDU3YjkzYjJlMDE2ZmM4Yzk5MQ%3D%3D&v=1&f=sd", - "is_gif": False, - "scrubber_media_url": "https://v.redd.it/eyvbxaeqtta51/DASH_96.mp4", - "transcoding_status": "completed", - "width": 640, - } - }, - "secure_media_embed": {}, - "selftext": "", - "selftext_html": None, - "send_replies": False, - "spoiler": False, - "stickied": False, - "subreddit": "aww", - "subreddit_id": "t5_2qh1o", - "subreddit_name_prefixed": "r/aww", - "subreddit_subscribers": 25634399, - "subreddit_type": "public", - "suggested_sort": None, - "thumbnail": "https://b.thumbs.redditmedia.com/WSBiDcoWPwAgSkt08uCI6TK7v_tdAdHmQHv7TePyTOs.jpg", - "thumbnail_height": 140, - "thumbnail_width": 140, - "title": "Cool catt and his clingy girlfriend", - "top_awarded_type": None, - "total_awards_received": 1, - "treatment_tags": [], - "ups": 11007, - "upvote_ratio": 0.99, - "url": "https://v.redd.it/eyvbxaeqtta51", - "url_overridden_by_dest": "https://v.redd.it/eyvbxaeqtta51", - "user_reports": [], - "view_count": None, - "visited": False, - "whitelist_status": "all_ads", - "wls": 6, - }, - "kind": "t3", - }, - { - "data": { - "all_awardings": [], - "allow_live_comments": False, - "approved_at_utc": None, - "approved_by": None, - "archived": False, - "author": "memezzer", - "author_flair_background_color": "", - "author_flair_css_class": "k", - "author_flair_richtext": [], - "author_flair_template_id": None, - "author_flair_text": None, - "author_flair_text_color": "dark", - "author_flair_type": "text", - "author_fullname": "t2_41jaebm4", - "author_patreon_flair": False, - "author_premium": True, - "awarders": [], - "banned_at_utc": None, - "banned_by": None, - "can_gild": True, - "can_mod_post": False, - "category": None, - "clicked": False, - "content_categories": None, - "contest_mode": False, - "created": 1594759625.0, - "created_utc": 1594730825.0, - "discussion_type": None, - "distinguished": None, - "domain": "v.redd.it", - "downs": 0, - "edited": False, - "gilded": 0, - "gildings": {}, - "hidden": False, - "hide_score": False, - "id": "hr0uzh", - "is_crosspostable": True, - "is_meta": False, - "is_original_content": False, - "is_reddit_media_domain": True, - "is_robot_indexable": True, - "is_self": False, - "is_video": True, - "likes": None, - "link_flair_background_color": "", - "link_flair_css_class": None, - "link_flair_richtext": [], - "link_flair_text": None, - "link_flair_text_color": "dark", - "link_flair_type": "text", - "locked": False, - "media": { - "reddit_video": { - "dash_url": "https://v.redd.it/y0mavwswjta51/DASHPlaylist.mpd?a=1597351258%2CYjU1NzFjOTE0YzY2OTdmODk3MGRiMGU4MjdhOGE5ODk2YWNiODQyMGUyOWRhNzI1M2U1MTEyZjBhOWZkZTZmMw%3D%3D&v=1&f=sd", - "duration": 8, - "fallback_url": "https://v.redd.it/y0mavwswjta51/DASH_720.mp4?source=fallback", - "height": 960, - "hls_url": "https://v.redd.it/y0mavwswjta51/HLSPlaylist.m3u8?a=1597351258%2CODk4NTdhMzA3NmY2ZmY2NGQxMmI2ZjcyMzk0ZTFhOTdhOGI4NGQ1NjBiMzNiMmVmZDBhMTQ4MGRkOWJlOWU1YQ%3D%3D&v=1&f=sd", - "is_gif": False, - "scrubber_media_url": "https://v.redd.it/y0mavwswjta51/DASH_96.mp4", - "transcoding_status": "completed", - "width": 960, - } - }, - "media_embed": {}, - "media_only": False, - "mod_note": None, - "mod_reason_by": None, - "mod_reason_title": None, - "mod_reports": [], - "name": "t3_hr0uzh", - "no_follow": False, - "num_comments": 86, - "num_crossposts": 3, - "num_reports": None, - "over_18": False, - "parent_whitelist_status": "all_ads", - "permalink": "/r/aww/comments/hr0uzh/good_pillow/", - "pinned": False, - "post_hint": "hosted:video", - "preview": { - "enabled": False, - "images": [ - { - "id": "neoTdGv5lMArlfu6euGUK_v_O87Lfmdrrz1ePTwzp1w", - "resolutions": [ - { - "height": 108, - "url": "https://external-preview.redd.it/E3alyJV8xErhjjDt_Qujcd0ZqmUhGOSFINXALm2FZAQ.png?width=108&crop=smart&format=pjpg&auto=webp&s=dcc1172b7ace007e8c72080519a16a487596d7e2", - "width": 108, - }, - { - "height": 216, - "url": "https://external-preview.redd.it/E3alyJV8xErhjjDt_Qujcd0ZqmUhGOSFINXALm2FZAQ.png?width=216&crop=smart&format=pjpg&auto=webp&s=a7968ce1aa34957a7f7103d06a66d4f9df95d437", - "width": 216, - }, - { - "height": 320, - "url": "https://external-preview.redd.it/E3alyJV8xErhjjDt_Qujcd0ZqmUhGOSFINXALm2FZAQ.png?width=320&crop=smart&format=pjpg&auto=webp&s=a2302d80948fba08e91db0a10db579341e1df712", - "width": 320, - }, - { - "height": 640, - "url": "https://external-preview.redd.it/E3alyJV8xErhjjDt_Qujcd0ZqmUhGOSFINXALm2FZAQ.png?width=640&crop=smart&format=pjpg&auto=webp&s=a8487450d38d14bcdfda2aeb659b453d8b1cacab", - "width": 640, - }, - { - "height": 960, - "url": "https://external-preview.redd.it/E3alyJV8xErhjjDt_Qujcd0ZqmUhGOSFINXALm2FZAQ.png?width=960&crop=smart&format=pjpg&auto=webp&s=d371bee68cab49130babe4b890c6323db128c214", - "width": 960, - }, - ], - "source": { - "height": 960, - "url": "https://external-preview.redd.it/E3alyJV8xErhjjDt_Qujcd0ZqmUhGOSFINXALm2FZAQ.png?format=pjpg&auto=webp&s=ff90de8f0a693afeca69dc85dbecb6af9783c769", - "width": 960, - }, - "variants": {}, - } - ], - }, - "pwls": 6, - "quarantine": False, - "removal_reason": None, - "removed_by": None, - "removed_by_category": None, - "report_reasons": None, - "saved": False, - "score": 13271, - "secure_media": { - "reddit_video": { - "dash_url": "https://v.redd.it/y0mavwswjta51/DASHPlaylist.mpd?a=1597351258%2CYjU1NzFjOTE0YzY2OTdmODk3MGRiMGU4MjdhOGE5ODk2YWNiODQyMGUyOWRhNzI1M2U1MTEyZjBhOWZkZTZmMw%3D%3D&v=1&f=sd", - "duration": 8, - "fallback_url": "https://v.redd.it/y0mavwswjta51/DASH_720.mp4?source=fallback", - "height": 960, - "hls_url": "https://v.redd.it/y0mavwswjta51/HLSPlaylist.m3u8?a=1597351258%2CODk4NTdhMzA3NmY2ZmY2NGQxMmI2ZjcyMzk0ZTFhOTdhOGI4NGQ1NjBiMzNiMmVmZDBhMTQ4MGRkOWJlOWU1YQ%3D%3D&v=1&f=sd", - "is_gif": False, - "scrubber_media_url": "https://v.redd.it/y0mavwswjta51/DASH_96.mp4", - "transcoding_status": "completed", - "width": 960, - } - }, - "secure_media_embed": {}, - "selftext": "", - "selftext_html": None, - "send_replies": True, - "spoiler": False, - "stickied": False, - "subreddit": "aww", - "subreddit_id": "t5_2qh1o", - "subreddit_name_prefixed": "r/aww", - "subreddit_subscribers": 25634399, - "subreddit_type": "public", - "suggested_sort": "confidence", - "thumbnail": "https://b.thumbs.redditmedia.com/sxFESWCVsSf4ij5_-a1xdJaFhSU2MjJ5T_TVFbook6Q.jpg", - "thumbnail_height": 140, - "thumbnail_width": 140, - "title": "Good pillow", - "top_awarded_type": None, - "total_awards_received": 0, - "treatment_tags": [], - "ups": 13271, - "upvote_ratio": 0.99, - "url": "https://v.redd.it/y0mavwswjta51", - "url_overridden_by_dest": "https://v.redd.it/y0mavwswjta51", - "user_reports": [], - "view_count": None, - "visited": False, - "whitelist_status": "all_ads", - "wls": 6, - }, - "kind": "t3", - }, - { - "data": { - "all_awardings": [], - "allow_live_comments": True, - "approved_at_utc": None, - "approved_by": None, - "archived": False, - "author": "asdfpartyy", - "author_flair_background_color": None, - "author_flair_css_class": None, - "author_flair_richtext": [], - "author_flair_template_id": None, - "author_flair_text": None, - "author_flair_text_color": None, - "author_flair_type": "text", - "author_fullname": "t2_t0ay0", - "author_patreon_flair": False, - "author_premium": True, - "awarders": [], - "banned_at_utc": None, - "banned_by": None, - "can_gild": True, - "can_mod_post": False, - "category": None, - "clicked": False, - "content_categories": None, - "contest_mode": False, - "created": 1594745472.0, - "created_utc": 1594716672.0, - "discussion_type": None, - "distinguished": None, - "domain": "v.redd.it", - "downs": 0, - "edited": False, - "gilded": 1, - "gildings": {"gid_2": 1}, - "hidden": False, - "hide_score": False, - "id": "hqy0ny", - "is_crosspostable": True, - "is_meta": False, - "is_original_content": False, - "is_reddit_media_domain": True, - "is_robot_indexable": True, - "is_self": False, - "is_video": True, - "likes": None, - "link_flair_background_color": "", - "link_flair_css_class": None, - "link_flair_richtext": [], - "link_flair_text": None, - "link_flair_text_color": "dark", - "link_flair_type": "text", - "locked": False, - "media": { - "reddit_video": { - "dash_url": "https://v.redd.it/asj4p03rdsa51/DASHPlaylist.mpd?a=1597351258%2CY2VmYTAyMWNmZjIwZjQ4YTBmMDc5MTRjOTU0NjliZWU3MDE2YTU3NjJiYzQxZWRiODY4ZTc1YWI1NDY4MWIxNA%3D%3D&v=1&f=sd", - "duration": 30, - "fallback_url": "https://v.redd.it/asj4p03rdsa51/DASH_360.mp4?source=fallback", - "height": 360, - "hls_url": "https://v.redd.it/asj4p03rdsa51/HLSPlaylist.m3u8?a=1597351258%2CY2QxM2I4Njk5MmIyOTRiZTBhNDQ2MDg0ZTM2NTllYzBjODBlYjNiNDc1Mzg2ODIxNDk4MTAzMzYyNzlmNjI1NQ%3D%3D&v=1&f=sd", - "is_gif": False, - "scrubber_media_url": "https://v.redd.it/asj4p03rdsa51/DASH_96.mp4", - "transcoding_status": "completed", - "width": 640, - } - }, - "media_embed": {}, - "media_only": False, - "mod_note": None, - "mod_reason_by": None, - "mod_reason_title": None, - "mod_reports": [], - "name": "t3_hqy0ny", - "no_follow": False, - "num_comments": 849, - "num_crossposts": 24, - "num_reports": None, - "over_18": False, - "parent_whitelist_status": "all_ads", - "permalink": "/r/aww/comments/hqy0ny/bunnies_flop_over_when_they_feel_completely_safe/", - "pinned": False, - "post_hint": "hosted:video", - "preview": { - "enabled": False, - "images": [ - { - "id": "eMi5JzdWDMeDALsqK8bVceX3jbXTWS_S1D-Ie1hQxnc", - "resolutions": [ - { - "height": 60, - "url": "https://external-preview.redd.it/e67OvQTnkypoYCuC4ApCsgdzVGjPLX3986XW_Ttm9bU.png?width=108&crop=smart&format=pjpg&auto=webp&s=5c6d61e0d4934df3c1f4b7a4c3c3afdd4c31c037", - "width": 108, - }, - { - "height": 121, - "url": "https://external-preview.redd.it/e67OvQTnkypoYCuC4ApCsgdzVGjPLX3986XW_Ttm9bU.png?width=216&crop=smart&format=pjpg&auto=webp&s=24586000b5821e23ce78f395c1f294bbe3fa3945", - "width": 216, - }, - { - "height": 180, - "url": "https://external-preview.redd.it/e67OvQTnkypoYCuC4ApCsgdzVGjPLX3986XW_Ttm9bU.png?width=320&crop=smart&format=pjpg&auto=webp&s=dcaed0109703cbddd4914e138afdb61086cffd81", - "width": 320, - }, - { - "height": 360, - "url": "https://external-preview.redd.it/e67OvQTnkypoYCuC4ApCsgdzVGjPLX3986XW_Ttm9bU.png?width=640&crop=smart&format=pjpg&auto=webp&s=ef4f6dc33fe582b93e954114e9eb1447bbbc197b", - "width": 640, - }, - ], - "source": { - "height": 360, - "url": "https://external-preview.redd.it/e67OvQTnkypoYCuC4ApCsgdzVGjPLX3986XW_Ttm9bU.png?format=pjpg&auto=webp&s=b6e8cba9d25c684ecb7104c1e1c454dba7fd3f2f", - "width": 640, - }, - "variants": {}, - } - ], - }, - "pwls": 6, - "quarantine": False, - "removal_reason": None, - "removed_by": None, - "removed_by_category": None, - "report_reasons": None, - "saved": False, - "score": 112661, - "secure_media": { - "reddit_video": { - "dash_url": "https://v.redd.it/asj4p03rdsa51/DASHPlaylist.mpd?a=1597351258%2CY2VmYTAyMWNmZjIwZjQ4YTBmMDc5MTRjOTU0NjliZWU3MDE2YTU3NjJiYzQxZWRiODY4ZTc1YWI1NDY4MWIxNA%3D%3D&v=1&f=sd", - "duration": 30, - "fallback_url": "https://v.redd.it/asj4p03rdsa51/DASH_360.mp4?source=fallback", - "height": 360, - "hls_url": "https://v.redd.it/asj4p03rdsa51/HLSPlaylist.m3u8?a=1597351258%2CY2QxM2I4Njk5MmIyOTRiZTBhNDQ2MDg0ZTM2NTllYzBjODBlYjNiNDc1Mzg2ODIxNDk4MTAzMzYyNzlmNjI1NQ%3D%3D&v=1&f=sd", - "is_gif": False, - "scrubber_media_url": "https://v.redd.it/asj4p03rdsa51/DASH_96.mp4", - "transcoding_status": "completed", - "width": 640, - } - }, - "secure_media_embed": {}, - "selftext": "", - "selftext_html": None, - "send_replies": True, - "spoiler": False, - "stickied": False, - "subreddit": "aww", - "subreddit_id": "t5_2qh1o", - "subreddit_name_prefixed": "r/aww", - "subreddit_subscribers": 25634399, - "subreddit_type": "public", - "suggested_sort": None, - "thumbnail": "https://b.thumbs.redditmedia.com/l_4Yk7NC8hz2HM0D3Hv2dK_nZBjpL8FL3NPv9WkRo8k.jpg", - "thumbnail_height": 78, - "thumbnail_width": 140, - "title": "Bunnies flop over when they feel " - "completely safe beside their " - "protectors", - "top_awarded_type": None, - "total_awards_received": 12, - "treatment_tags": [], - "ups": 112661, - "upvote_ratio": 0.94, - "url": "https://v.redd.it/asj4p03rdsa51", - "url_overridden_by_dest": "https://v.redd.it/asj4p03rdsa51", - "user_reports": [], - "view_count": None, - "visited": False, - "whitelist_status": "all_ads", - "wls": 6, - }, - "kind": "t3", - }, - ], - "dist": 25, - "modhash": None, - }, - "kind": "Listing", -} - -external_video_mock = { - "data": { - "after": "t3_hr3mhe", - "before": None, - "children": [ - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "aww", - "selftext": "", - "author_fullname": "t2_ot2b2", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Dog splashing in water", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/aww", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "thumbnail_height": 140, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hulh8k", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.94, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 1142, - "total_awards_received": 0, - "media_embed": { - "content": '<iframe class="embedly-embed" src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fgfycat.com%2Fifr%2Fexcellentinfantileamericanwigeon&display_name=Gfycat&url=https%3A%2F%2Fgfycat.com%2Fexcellentinfantileamericanwigeon&image=https%3A%2F%2Fthumbs.gfycat.com%2FExcellentInfantileAmericanwigeon-size_restricted.gif&key=ed8fa8699ce04833838e66ce79ba05f1&type=text%2Fhtml&schema=gfycat" width="400" height="400" scrolling="no" title="Gfycat embed" frameborder="0" allow="autoplay; fullscreen" allowfullscreen="True"></iframe>', - "width": 400, - "scrolling": False, - "height": 400, - }, - "thumbnail_width": 140, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": { - "oembed": { - "provider_url": "https://gfycat.com", - "description": 'Hi! We use cookies and similar technologies ("cookies"), including third-party cookies, on this website to help operate and improve your experience on our site, monitor our site performance, and for advertising purposes. By clicking "Accept Cookies" below, you are giving us consent to use cookies (except consent is not required for cookies necessary to run our site).', - "title": "97991217 286625482366728 7551185146460766208 n", - "author_name": "Gfycat", - "height": 400, - "width": 400, - "html": '<iframe class="embedly-embed" src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fgfycat.com%2Fifr%2Fexcellentinfantileamericanwigeon&display_name=Gfycat&url=https%3A%2F%2Fgfycat.com%2Fexcellentinfantileamericanwigeon&image=https%3A%2F%2Fthumbs.gfycat.com%2FExcellentInfantileAmericanwigeon-size_restricted.gif&key=ed8fa8699ce04833838e66ce79ba05f1&type=text%2Fhtml&schema=gfycat" width="400" height="400" scrolling="no" title="Gfycat embed" frameborder="0" allow="autoplay; fullscreen" allowfullscreen="True"></iframe>', - "thumbnail_width": 250, - "version": "1.0", - "provider_name": "Gfycat", - "thumbnail_url": "https://thumbs.gfycat.com/ExcellentInfantileAmericanwigeon-size_restricted.gif", - "type": "video", - "thumbnail_height": 250, - }, - "type": "gfycat.com", - }, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": { - "content": '<iframe class="embedly-embed" src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fgfycat.com%2Fifr%2Fexcellentinfantileamericanwigeon&display_name=Gfycat&url=https%3A%2F%2Fgfycat.com%2Fexcellentinfantileamericanwigeon&image=https%3A%2F%2Fthumbs.gfycat.com%2FExcellentInfantileAmericanwigeon-size_restricted.gif&key=ed8fa8699ce04833838e66ce79ba05f1&type=text%2Fhtml&schema=gfycat" width="400" height="400" scrolling="no" title="Gfycat embed" frameborder="0" allow="autoplay; fullscreen" allowfullscreen="True"></iframe>', - "width": 400, - "scrolling": False, - "media_domain_url": "https://www.redditmedia.com/mediaembed/hulh8k", - "height": 400, - }, - "link_flair_text": None, - "can_mod_post": False, - "score": 1142, - "approved_by": None, - "author_premium": False, - "thumbnail": "https://b.thumbs.redditmedia.com/eR_Cu4w1l9PwaM14RTEpnKD20EaK5mMxUbyK8BBDo_M.jpg", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "post_hint": "rich:video", - "content_categories": None, - "is_self": False, - "mod_note": None, - "crosspost_parent_list": [], - "created": 1595281442, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "gfycat.com", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://gfycat.com/excellentinfantileamericanwigeon", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "preview": { - "images": [ - { - "source": { - "url": "https://external-preview.redd.it/rZXN_aGbww8NNlhGWB-5cjHPonSST4S7aS6uaZyb_W4.jpg?auto=webp&s=2a2d3a1e0a06742bf752c1c4e1582c2fa49793a3", - "width": 250, - "height": 250, - }, - "resolutions": [ - { - "url": "https://external-preview.redd.it/rZXN_aGbww8NNlhGWB-5cjHPonSST4S7aS6uaZyb_W4.jpg?width=108&crop=smart&auto=webp&s=35f61b003416516f664682717876a94d186793ae", - "width": 108, - "height": 108, - }, - { - "url": "https://external-preview.redd.it/rZXN_aGbww8NNlhGWB-5cjHPonSST4S7aS6uaZyb_W4.jpg?width=216&crop=smart&auto=webp&s=842416c1b8f8fae758a7ba6eb98af93ee2404a8d", - "width": 216, - "height": 216, - }, - ], - "variants": {}, - "id": "IVorc9dV9K9nJhhSVFKST92dfGfmhgBQjw257DWmJcE", - } - ], - "reddit_video_preview": { - "fallback_url": "https://v.redd.it/syp9pkiu00c51/DASH_360.mp4", - "height": 400, - "width": 400, - "scrubber_media_url": "https://v.redd.it/syp9pkiu00c51/DASH_96.mp4", - "dash_url": "https://v.redd.it/syp9pkiu00c51/DASHPlaylist.mpd", - "duration": 21, - "hls_url": "https://v.redd.it/syp9pkiu00c51/HLSPlaylist.m3u8", - "is_gif": True, - "transcoding_status": "completed", - }, - "enabled": False, - }, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1o", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hulh8k", - "is_robot_indexable": True, - "report_reasons": None, - "author": "TheRikari", - "discussion_type": None, - "num_comments": 21, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "crosspost_parent": "t3_hujqxu", - "author_flair_text_color": None, - "permalink": "/r/aww/comments/hulh8k/dog_splashing_in_water/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://gfycat.com/excellentinfantileamericanwigeon", - "subreddit_subscribers": 25721914, - "created_utc": 1595252642, - "num_crossposts": 0, - "media": { - "oembed": { - "provider_url": "https://gfycat.com", - "description": 'Hi! We use cookies and similar technologies ("cookies"), including third-party cookies, on this website to help operate and improve your experience on our site, monitor our site performance, and for advertising purposes. By clicking "Accept Cookies" below, you are giving us consent to use cookies (except consent is not required for cookies necessary to run our site).', - "title": "97991217 286625482366728 7551185146460766208 n", - "author_name": "Gfycat", - "height": 400, - "width": 400, - "html": '<iframe class="embedly-embed" src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fgfycat.com%2Fifr%2Fexcellentinfantileamericanwigeon&display_name=Gfycat&url=https%3A%2F%2Fgfycat.com%2Fexcellentinfantileamericanwigeon&image=https%3A%2F%2Fthumbs.gfycat.com%2FExcellentInfantileAmericanwigeon-size_restricted.gif&key=ed8fa8699ce04833838e66ce79ba05f1&type=text%2Fhtml&schema=gfycat" width="400" height="400" scrolling="no" title="Gfycat embed" frameborder="0" allow="autoplay; fullscreen" allowfullscreen="True"></iframe>', - "thumbnail_width": 250, - "version": "1.0", - "provider_name": "Gfycat", - "thumbnail_url": "https://thumbs.gfycat.com/ExcellentInfantileAmericanwigeon-size_restricted.gif", - "type": "video", - "thumbnail_height": 250, - }, - "type": "gfycat.com", - }, - "is_video": False, - }, - } - ], - "dist": 25, - "modhash": None, - }, - "kind": "Listing", -} - -external_gifv_mock = { - "data": { - "after": "t3_hr3mhe", - "before": None, - "children": [ - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "aww", - "selftext": "", - "author_fullname": "t2_ygx0p1u", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "if i fits i sits", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/aww", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "thumbnail_height": 74, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_humdlf", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.97, - "author_flair_background_color": "", - "subreddit_type": "public", - "ups": 7512, - "total_awards_received": 1, - "media_embed": {}, - "thumbnail_width": 140, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 7512, - "approved_by": None, - "author_premium": True, - "thumbnail": "https://b.thumbs.redditmedia.com/QHK44nUFZup-hfFX2Z1dXhk-1lPEmROUCB3bBujvTck.jpg", - "edited": False, - "author_flair_css_class": "k", - "author_flair_richtext": [], - "gildings": {}, - "post_hint": "link", - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1595284712, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "i.imgur.com", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": "confidence", - "banned_at_utc": None, - "url_overridden_by_dest": "https://i.imgur.com/grVh2AG.gifv", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": False, - "pinned": False, - "over_18": False, - "preview": { - "images": [ - { - "source": { - "url": "https://external-preview.redd.it/XDHMFAMlcn3iuSqmWeDIc4yG-q-6xnTIdGivYX-cus4.jpg?auto=webp&s=c4ba246318b3502b080d37fcbdb12e07221401a9", - "width": 638, - "height": 338, - }, - "resolutions": [ - { - "url": "https://external-preview.redd.it/XDHMFAMlcn3iuSqmWeDIc4yG-q-6xnTIdGivYX-cus4.jpg?width=108&crop=smart&auto=webp&s=c9c340a60ba3da1af3f5d5c08f3ed618ebd567d4", - "width": 108, - "height": 57, - }, - { - "url": "https://external-preview.redd.it/XDHMFAMlcn3iuSqmWeDIc4yG-q-6xnTIdGivYX-cus4.jpg?width=216&crop=smart&auto=webp&s=d05c0415e3dc63d097264bfb1b35b09676bd24f6", - "width": 216, - "height": 114, - }, - { - "url": "https://external-preview.redd.it/XDHMFAMlcn3iuSqmWeDIc4yG-q-6xnTIdGivYX-cus4.jpg?width=320&crop=smart&auto=webp&s=5c236179ccfff29e9ba980f31d5a6a9905adbe86", - "width": 320, - "height": 169, - }, - ], - "variants": {}, - "id": "4Z8zF5e4sZJnX4vWH7pZkbqiDPMCuh2J4kNotV9AGSI", - } - ], - "reddit_video_preview": { - "fallback_url": "https://v.redd.it/zzctc8y2dzb51/DASH_240.mp4", - "height": 338, - "width": 638, - "scrubber_media_url": "https://v.redd.it/zzctc8y2dzb51/DASH_96.mp4", - "dash_url": "https://v.redd.it/zzctc8y2dzb51/DASHPlaylist.mpd", - "duration": 44, - "hls_url": "https://v.redd.it/zzctc8y2dzb51/HLSPlaylist.m3u8", - "is_gif": True, - "transcoding_status": "completed", - }, - "enabled": False, - }, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": False, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1o", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "humdlf", - "is_robot_indexable": True, - "report_reasons": None, - "author": "jasontaken", - "discussion_type": None, - "num_comments": 67, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": "dark", - "permalink": "/r/aww/comments/humdlf/if_i_fits_i_sits/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://i.imgur.com/grVh2AG.gifv", - "subreddit_subscribers": 25723833, - "created_utc": 1595255912, - "num_crossposts": 1, - "media": None, - "is_video": False, - }, - } - ], - "dist": 25, - "modhash": None, - }, - "kind": "Listing", -} - -unknown_mock = { - "data": { - "after": "t3_hr3mhe", - "before": None, - "children": [ - { - "kind": "t1", - "data": { - "approved_at_utc": None, - "subreddit": "aww", - "selftext": "", - "author_fullname": "t2_ygx0p1u", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "if i fits i sits", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/aww", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "thumbnail_height": 74, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_humdlf", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.97, - "author_flair_background_color": "", - "subreddit_type": "public", - "ups": 7512, - "total_awards_received": 1, - "media_embed": {}, - "thumbnail_width": 140, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 7512, - "approved_by": None, - "author_premium": True, - "thumbnail": "https://b.thumbs.redditmedia.com/QHK44nUFZup-hfFX2Z1dXhk-1lPEmROUCB3bBujvTck.jpg", - "edited": False, - "author_flair_css_class": "k", - "author_flair_richtext": [], - "gildings": {}, - "post_hint": "link", - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1595284712, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "i.imgur.com", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": "confidence", - "banned_at_utc": None, - "url_overridden_by_dest": "https://i.imgur.com/grVh2AG.gifv", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": False, - "pinned": False, - "over_18": False, - "preview": { - "images": [ - { - "source": { - "url": "https://external-preview.redd.it/XDHMFAMlcn3iuSqmWeDIc4yG-q-6xnTIdGivYX-cus4.jpg?auto=webp&s=c4ba246318b3502b080d37fcbdb12e07221401a9", - "width": 638, - "height": 338, - }, - "resolutions": [ - { - "url": "https://external-preview.redd.it/XDHMFAMlcn3iuSqmWeDIc4yG-q-6xnTIdGivYX-cus4.jpg?width=108&crop=smart&auto=webp&s=c9c340a60ba3da1af3f5d5c08f3ed618ebd567d4", - "width": 108, - "height": 57, - }, - { - "url": "https://external-preview.redd.it/XDHMFAMlcn3iuSqmWeDIc4yG-q-6xnTIdGivYX-cus4.jpg?width=216&crop=smart&auto=webp&s=d05c0415e3dc63d097264bfb1b35b09676bd24f6", - "width": 216, - "height": 114, - }, - { - "url": "https://external-preview.redd.it/XDHMFAMlcn3iuSqmWeDIc4yG-q-6xnTIdGivYX-cus4.jpg?width=320&crop=smart&auto=webp&s=5c236179ccfff29e9ba980f31d5a6a9905adbe86", - "width": 320, - "height": 169, - }, - ], - "variants": {}, - "id": "4Z8zF5e4sZJnX4vWH7pZkbqiDPMCuh2J4kNotV9AGSI", - } - ], - "reddit_video_preview": { - "fallback_url": "https://v.redd.it/zzctc8y2dzb51/DASH_240.mp4", - "height": 338, - "width": 638, - "scrubber_media_url": "https://v.redd.it/zzctc8y2dzb51/DASH_96.mp4", - "dash_url": "https://v.redd.it/zzctc8y2dzb51/DASHPlaylist.mpd", - "duration": 44, - "hls_url": "https://v.redd.it/zzctc8y2dzb51/HLSPlaylist.m3u8", - "is_gif": True, - "transcoding_status": "completed", - }, - "enabled": False, - }, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": False, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1o", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "humdlf", - "is_robot_indexable": True, - "report_reasons": None, - "author": "jasontaken", - "discussion_type": None, - "num_comments": 67, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": "dark", - "permalink": "/r/aww/comments/humdlf/if_i_fits_i_sits/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://i.imgur.com/grVh2AG.gifv", - "subreddit_subscribers": 25723833, - "created_utc": 1595255912, - "num_crossposts": 1, - "media": None, - "is_video": False, - }, - } - ], - "dist": 25, - "modhash": None, - }, - "kind": "Listing", -} - -nsfw_mock = { - "kind": "Listing", - "data": { - "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", - "dist": 27, - "children": [ - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "Welcome to r/linux rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.\n\nLet us know what's annoying you, whats making you happy, or something that you want to get out to r/linux but didn't make the cut into a full post of it's own.\n\nFor those looking for certifications please use this megathread to ask about how to get certified whether it's for the business world or for your own satisfaction. Be sure to check out r/linuxadmin for more discussion in the SysAdmin world!\n\n_Please keep questions in r/linuxquestions, r/linux4noobs, or the Wednesday automod thread._", - "author_fullname": "t2_6l4z3", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hm0qct", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.7, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 8, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 8, - "approved_by": None, - "author_premium": True, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594037482.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.linux", - "allow_live_comments": False, - "selftext_html": "<!-- SC_OFF --><div class='md'><p>Welcome to <a href='/r/linux'>r/linux</a> rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.</p>\n\n<p>Let us know what&#39;s annoying you, whats making you happy, or something that you want to get out to <a href='/r/linux'>r/linux</a> but didn&#39;t make the cut into a full post of it&#39;s own.</p>\n\n<p>For those looking for certifications please use this megathread to ask about how to get certified whether it&#39;s for the business world or for your own satisfaction. Be sure to check out <a href='/r/linuxadmin'>r/linuxadmin</a> for more discussion in the SysAdmin world!</p>\n\n<p><em>Please keep questions in <a href='/r/linuxquestions'>r/linuxquestions</a>, <a href='/r/linux4noobs'>r/linux4noobs</a>, or the Wednesday automod thread.</em></p>\n</div><!-- SC_ON -->", - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": True, - "is_crosspostable": True, - "pinned": False, - "over_18": True, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": "moderator", - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hm0qct", - "is_robot_indexable": True, - "report_reasons": None, - "author": "AutoModerator", - "discussion_type": None, - "num_comments": 9, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", - "parent_whitelist_status": "all_ads", - "stickied": True, - "url": "https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", - "subreddit_subscribers": 544037, - "created_utc": 1594008682.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "Welcome to r/linux! If you're new to Linux or trying to get started this thread is for you. Get help here or as always, check out r/linuxquestions or r/linux4noobs\n\nThis megathread is for all your question needs. As we don't allow questions on r/linux outside of this megathread, please consider using r/linuxquestions or r/linux4noobs for the best solution to your problem.\n\nAsk your hardware requests here too or try r/linuxhardware!", - "author_fullname": "t2_6l4z3", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Weekly Questions and Hardware Thread - July 08, 2020", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hna75r", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.6, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 2, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 2, - "approved_by": None, - "author_premium": True, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594210138.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.linux", - "allow_live_comments": False, - "selftext_html": '<!-- SC_OFF --><div class="md"><p>Welcome to <a href="/r/linux">r/linux</a>! If you&#39;re new to Linux or trying to get started this thread is for you. Get help here or as always, check out <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a></p>\n\n<p>This megathread is for all your question needs. As we don&#39;t allow questions on <a href="/r/linux">r/linux</a> outside of this megathread, please consider using <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a> for the best solution to your problem.</p>\n\n<p>Ask your hardware requests here too or try <a href="/r/linuxhardware">r/linuxhardware</a>!</p>\n</div><!-- SC_ON -->', - "likes": None, - "suggested_sort": "new", - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": True, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": "moderator", - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hna75r", - "is_robot_indexable": True, - "report_reasons": None, - "author": "AutoModerator", - "discussion_type": None, - "num_comments": 2, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", - "parent_whitelist_status": "all_ads", - "stickied": True, - "url": "https://www.reddit.com/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", - "subreddit_subscribers": 544037, - "created_utc": 1594181338.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - ], - "after": "t3_hmytic", - "before": None, - }, -} - -spoiler_mock = { - "kind": "Listing", - "data": { - "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", - "dist": 27, - "children": [ - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "Welcome to r/linux rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.\n\nLet us know what's annoying you, whats making you happy, or something that you want to get out to r/linux but didn't make the cut into a full post of it's own.\n\nFor those looking for certifications please use this megathread to ask about how to get certified whether it's for the business world or for your own satisfaction. Be sure to check out r/linuxadmin for more discussion in the SysAdmin world!\n\n_Please keep questions in r/linuxquestions, r/linux4noobs, or the Wednesday automod thread._", - "author_fullname": "t2_6l4z3", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hm0qct", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.7, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 8, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 8, - "approved_by": None, - "author_premium": True, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594037482.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.linux", - "allow_live_comments": False, - "selftext_html": "<!-- SC_OFF --><div class='md'><p>Welcome to <a href='/r/linux'>r/linux</a> rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.</p>\n\n<p>Let us know what&#39;s annoying you, whats making you happy, or something that you want to get out to <a href='/r/linux'>r/linux</a> but didn&#39;t make the cut into a full post of it&#39;s own.</p>\n\n<p>For those looking for certifications please use this megathread to ask about how to get certified whether it&#39;s for the business world or for your own satisfaction. Be sure to check out <a href='/r/linuxadmin'>r/linuxadmin</a> for more discussion in the SysAdmin world!</p>\n\n<p><em>Please keep questions in <a href='/r/linuxquestions'>r/linuxquestions</a>, <a href='/r/linux4noobs'>r/linux4noobs</a>, or the Wednesday automod thread.</em></p>\n</div><!-- SC_ON -->", - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": True, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": "moderator", - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hm0qct", - "is_robot_indexable": True, - "report_reasons": None, - "author": "AutoModerator", - "discussion_type": None, - "num_comments": 9, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", - "parent_whitelist_status": "all_ads", - "stickied": True, - "url": "https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", - "subreddit_subscribers": 544037, - "created_utc": 1594008682.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "Welcome to r/linux! If you're new to Linux or trying to get started this thread is for you. Get help here or as always, check out r/linuxquestions or r/linux4noobs\n\nThis megathread is for all your question needs. As we don't allow questions on r/linux outside of this megathread, please consider using r/linuxquestions or r/linux4noobs for the best solution to your problem.\n\nAsk your hardware requests here too or try r/linuxhardware!", - "author_fullname": "t2_6l4z3", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Weekly Questions and Hardware Thread - July 08, 2020", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hna75r", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.6, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 2, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 2, - "approved_by": None, - "author_premium": True, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594210138.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.linux", - "allow_live_comments": False, - "selftext_html": '<!-- SC_OFF --><div class="md"><p>Welcome to <a href="/r/linux">r/linux</a>! If you&#39;re new to Linux or trying to get started this thread is for you. Get help here or as always, check out <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a></p>\n\n<p>This megathread is for all your question needs. As we don&#39;t allow questions on <a href="/r/linux">r/linux</a> outside of this megathread, please consider using <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a> for the best solution to your problem.</p>\n\n<p>Ask your hardware requests here too or try <a href="/r/linuxhardware">r/linuxhardware</a>!</p>\n</div><!-- SC_ON -->', - "likes": None, - "suggested_sort": "new", - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": True, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": True, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": "moderator", - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hna75r", - "is_robot_indexable": True, - "report_reasons": None, - "author": "AutoModerator", - "discussion_type": None, - "num_comments": 2, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", - "parent_whitelist_status": "all_ads", - "stickied": True, - "url": "https://www.reddit.com/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", - "subreddit_subscribers": 544037, - "created_utc": 1594181338.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - ], - "after": "t3_hmytic", - "before": None, - }, -} - -seen_mock = { - "kind": "Listing", - "data": { - "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", - "dist": 27, - "children": [ - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "Welcome to r/linux rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.\n\nLet us know what's annoying you, whats making you happy, or something that you want to get out to r/linux but didn't make the cut into a full post of it's own.\n\nFor those looking for certifications please use this megathread to ask about how to get certified whether it's for the business world or for your own satisfaction. Be sure to check out r/linuxadmin for more discussion in the SysAdmin world!\n\n_Please keep questions in r/linuxquestions, r/linux4noobs, or the Wednesday automod thread._", - "author_fullname": "t2_6l4z3", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": True, - "title": "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hm0qct", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.7, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 8, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 8, - "approved_by": None, - "author_premium": True, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594037482.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.linux", - "allow_live_comments": False, - "selftext_html": "<!-- SC_OFF --><div class='md'><p>Welcome to <a href='/r/linux'>r/linux</a> rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.</p>\n\n<p>Let us know what&#39;s annoying you, whats making you happy, or something that you want to get out to <a href='/r/linux'>r/linux</a> but didn&#39;t make the cut into a full post of it&#39;s own.</p>\n\n<p>For those looking for certifications please use this megathread to ask about how to get certified whether it&#39;s for the business world or for your own satisfaction. Be sure to check out <a href='/r/linuxadmin'>r/linuxadmin</a> for more discussion in the SysAdmin world!</p>\n\n<p><em>Please keep questions in <a href='/r/linuxquestions'>r/linuxquestions</a>, <a href='/r/linux4noobs'>r/linux4noobs</a>, or the Wednesday automod thread.</em></p>\n</div><!-- SC_ON -->", - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": True, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": "moderator", - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hm0qct", - "is_robot_indexable": True, - "report_reasons": None, - "author": "AutoModerator", - "discussion_type": None, - "num_comments": 9, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", - "parent_whitelist_status": "all_ads", - "stickied": True, - "url": "https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", - "subreddit_subscribers": 544037, - "created_utc": 1594008682.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "Welcome to r/linux! If you're new to Linux or trying to get started this thread is for you. Get help here or as always, check out r/linuxquestions or r/linux4noobs\n\nThis megathread is for all your question needs. As we don't allow questions on r/linux outside of this megathread, please consider using r/linuxquestions or r/linux4noobs for the best solution to your problem.\n\nAsk your hardware requests here too or try r/linuxhardware!", - "author_fullname": "t2_6l4z3", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Weekly Questions and Hardware Thread - July 08, 2020", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hna75r", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.6, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 2, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 2, - "approved_by": None, - "author_premium": True, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594210138.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.linux", - "allow_live_comments": False, - "selftext_html": '<!-- SC_OFF --><div class="md"><p>Welcome to <a href="/r/linux">r/linux</a>! If you&#39;re new to Linux or trying to get started this thread is for you. Get help here or as always, check out <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a></p>\n\n<p>This megathread is for all your question needs. As we don&#39;t allow questions on <a href="/r/linux">r/linux</a> outside of this megathread, please consider using <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a> for the best solution to your problem.</p>\n\n<p>Ask your hardware requests here too or try <a href="/r/linuxhardware">r/linuxhardware</a>!</p>\n</div><!-- SC_ON -->', - "likes": None, - "suggested_sort": "new", - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": True, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": "moderator", - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hna75r", - "is_robot_indexable": True, - "report_reasons": None, - "author": "AutoModerator", - "discussion_type": None, - "num_comments": 2, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", - "parent_whitelist_status": "all_ads", - "stickied": True, - "url": "https://www.reddit.com/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", - "subreddit_subscribers": 544037, - "created_utc": 1594181338.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - ], - "after": "t3_hmytic", - "before": None, - }, -} - -upvote_mock = { - "kind": "Listing", - "data": { - "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", - "dist": 27, - "children": [ - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "Welcome to r/linux rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.\n\nLet us know what's annoying you, whats making you happy, or something that you want to get out to r/linux but didn't make the cut into a full post of it's own.\n\nFor those looking for certifications please use this megathread to ask about how to get certified whether it's for the business world or for your own satisfaction. Be sure to check out r/linuxadmin for more discussion in the SysAdmin world!\n\n_Please keep questions in r/linuxquestions, r/linux4noobs, or the Wednesday automod thread._", - "author_fullname": "t2_6l4z3", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": True, - "title": "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hm0qct", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.7, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 99, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 8, - "approved_by": None, - "author_premium": True, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594037482.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.linux", - "allow_live_comments": False, - "selftext_html": "<!-- SC_OFF --><div class='md'><p>Welcome to <a href='/r/linux'>r/linux</a> rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.</p>\n\n<p>Let us know what&#39;s annoying you, whats making you happy, or something that you want to get out to <a href='/r/linux'>r/linux</a> but didn&#39;t make the cut into a full post of it&#39;s own.</p>\n\n<p>For those looking for certifications please use this megathread to ask about how to get certified whether it&#39;s for the business world or for your own satisfaction. Be sure to check out <a href='/r/linuxadmin'>r/linuxadmin</a> for more discussion in the SysAdmin world!</p>\n\n<p><em>Please keep questions in <a href='/r/linuxquestions'>r/linuxquestions</a>, <a href='/r/linux4noobs'>r/linux4noobs</a>, or the Wednesday automod thread.</em></p>\n</div><!-- SC_ON -->", - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": True, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": "moderator", - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hm0qct", - "is_robot_indexable": True, - "report_reasons": None, - "author": "AutoModerator", - "discussion_type": None, - "num_comments": 9, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", - "parent_whitelist_status": "all_ads", - "stickied": True, - "url": "https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", - "subreddit_subscribers": 544037, - "created_utc": 1594008682.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "Welcome to r/linux! If you're new to Linux or trying to get started this thread is for you. Get help here or as always, check out r/linuxquestions or r/linux4noobs\n\nThis megathread is for all your question needs. As we don't allow questions on r/linux outside of this megathread, please consider using r/linuxquestions or r/linux4noobs for the best solution to your problem.\n\nAsk your hardware requests here too or try r/linuxhardware!", - "author_fullname": "t2_6l4z3", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Weekly Questions and Hardware Thread - July 08, 2020", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hna75r", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.6, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 150, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 2, - "approved_by": None, - "author_premium": True, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594210138.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.linux", - "allow_live_comments": False, - "selftext_html": '<!-- SC_OFF --><div class="md"><p>Welcome to <a href="/r/linux">r/linux</a>! If you&#39;re new to Linux or trying to get started this thread is for you. Get help here or as always, check out <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a></p>\n\n<p>This megathread is for all your question needs. As we don&#39;t allow questions on <a href="/r/linux">r/linux</a> outside of this megathread, please consider using <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a> for the best solution to your problem.</p>\n\n<p>Ask your hardware requests here too or try <a href="/r/linuxhardware">r/linuxhardware</a>!</p>\n</div><!-- SC_ON -->', - "likes": None, - "suggested_sort": "new", - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": True, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": "moderator", - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hna75r", - "is_robot_indexable": True, - "report_reasons": None, - "author": "AutoModerator", - "discussion_type": None, - "num_comments": 2, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", - "parent_whitelist_status": "all_ads", - "stickied": True, - "url": "https://www.reddit.com/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", - "subreddit_subscribers": 544037, - "created_utc": 1594181338.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - ], - "after": "t3_hmytic", - "before": None, - }, -} - -comment_mock = { - "kind": "Listing", - "data": { - "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", - "dist": 27, - "children": [ - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "Welcome to r/linux rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.\n\nLet us know what's annoying you, whats making you happy, or something that you want to get out to r/linux but didn't make the cut into a full post of it's own.\n\nFor those looking for certifications please use this megathread to ask about how to get certified whether it's for the business world or for your own satisfaction. Be sure to check out r/linuxadmin for more discussion in the SysAdmin world!\n\n_Please keep questions in r/linuxquestions, r/linux4noobs, or the Wednesday automod thread._", - "author_fullname": "t2_6l4z3", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": True, - "title": "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hm0qct", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.7, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 99, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 8, - "approved_by": None, - "author_premium": True, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594037482.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.linux", - "allow_live_comments": False, - "selftext_html": "<!-- SC_OFF --><div class='md'><p>Welcome to <a href='/r/linux'>r/linux</a> rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.</p>\n\n<p>Let us know what&#39;s annoying you, whats making you happy, or something that you want to get out to <a href='/r/linux'>r/linux</a> but didn&#39;t make the cut into a full post of it&#39;s own.</p>\n\n<p>For those looking for certifications please use this megathread to ask about how to get certified whether it&#39;s for the business world or for your own satisfaction. Be sure to check out <a href='/r/linuxadmin'>r/linuxadmin</a> for more discussion in the SysAdmin world!</p>\n\n<p><em>Please keep questions in <a href='/r/linuxquestions'>r/linuxquestions</a>, <a href='/r/linux4noobs'>r/linux4noobs</a>, or the Wednesday automod thread.</em></p>\n</div><!-- SC_ON -->", - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": True, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": "moderator", - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hm0qct", - "is_robot_indexable": True, - "report_reasons": None, - "author": "AutoModerator", - "discussion_type": None, - "num_comments": 150, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", - "parent_whitelist_status": "all_ads", - "stickied": True, - "url": "https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", - "subreddit_subscribers": 544037, - "created_utc": 1594008682.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "Welcome to r/linux! If you're new to Linux or trying to get started this thread is for you. Get help here or as always, check out r/linuxquestions or r/linux4noobs\n\nThis megathread is for all your question needs. As we don't allow questions on r/linux outside of this megathread, please consider using r/linuxquestions or r/linux4noobs for the best solution to your problem.\n\nAsk your hardware requests here too or try r/linuxhardware!", - "author_fullname": "t2_6l4z3", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Weekly Questions and Hardware Thread - July 08, 2020", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hna75r", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.6, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 150, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 2, - "approved_by": None, - "author_premium": True, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594210138.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.linux", - "allow_live_comments": False, - "selftext_html": '<!-- SC_OFF --><div class="md"><p>Welcome to <a href="/r/linux">r/linux</a>! If you&#39;re new to Linux or trying to get started this thread is for you. Get help here or as always, check out <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a></p>\n\n<p>This megathread is for all your question needs. As we don&#39;t allow questions on <a href="/r/linux">r/linux</a> outside of this megathread, please consider using <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a> for the best solution to your problem.</p>\n\n<p>Ask your hardware requests here too or try <a href="/r/linuxhardware">r/linuxhardware</a>!</p>\n</div><!-- SC_ON -->', - "likes": None, - "suggested_sort": "new", - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": True, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": "moderator", - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hna75r", - "is_robot_indexable": True, - "report_reasons": None, - "author": "AutoModerator", - "discussion_type": None, - "num_comments": 80, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", - "parent_whitelist_status": "all_ads", - "stickied": True, - "url": "https://www.reddit.com/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", - "subreddit_subscribers": 544037, - "created_utc": 1594181338.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - ], - "after": "t3_hmytic", - "before": None, - }, -} - -downvote_mock = { - "kind": "Listing", - "data": { - "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", - "dist": 27, - "children": [ - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "Welcome to r/linux rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.\n\nLet us know what's annoying you, whats making you happy, or something that you want to get out to r/linux but didn't make the cut into a full post of it's own.\n\nFor those looking for certifications please use this megathread to ask about how to get certified whether it's for the business world or for your own satisfaction. Be sure to check out r/linuxadmin for more discussion in the SysAdmin world!\n\n_Please keep questions in r/linuxquestions, r/linux4noobs, or the Wednesday automod thread._", - "author_fullname": "t2_6l4z3", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": True, - "title": "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 10, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hm0qct", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.7, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 99, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 8, - "approved_by": None, - "author_premium": True, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594037482.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.linux", - "allow_live_comments": False, - "selftext_html": "<!-- SC_OFF --><div class='md'><p>Welcome to <a href='/r/linux'>r/linux</a> rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.</p>\n\n<p>Let us know what&#39;s annoying you, whats making you happy, or something that you want to get out to <a href='/r/linux'>r/linux</a> but didn&#39;t make the cut into a full post of it&#39;s own.</p>\n\n<p>For those looking for certifications please use this megathread to ask about how to get certified whether it&#39;s for the business world or for your own satisfaction. Be sure to check out <a href='/r/linuxadmin'>r/linuxadmin</a> for more discussion in the SysAdmin world!</p>\n\n<p><em>Please keep questions in <a href='/r/linuxquestions'>r/linuxquestions</a>, <a href='/r/linux4noobs'>r/linux4noobs</a>, or the Wednesday automod thread.</em></p>\n</div><!-- SC_ON -->", - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": True, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": "moderator", - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hm0qct", - "is_robot_indexable": True, - "report_reasons": None, - "author": "AutoModerator", - "discussion_type": None, - "num_comments": 150, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", - "parent_whitelist_status": "all_ads", - "stickied": True, - "url": "https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", - "subreddit_subscribers": 544037, - "created_utc": 1594008682.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "Welcome to r/linux! If you're new to Linux or trying to get started this thread is for you. Get help here or as always, check out r/linuxquestions or r/linux4noobs\n\nThis megathread is for all your question needs. As we don't allow questions on r/linux outside of this megathread, please consider using r/linuxquestions or r/linux4noobs for the best solution to your problem.\n\nAsk your hardware requests here too or try r/linuxhardware!", - "author_fullname": "t2_6l4z3", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Weekly Questions and Hardware Thread - July 08, 2020", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 40, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hna75r", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.6, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 150, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 2, - "approved_by": None, - "author_premium": True, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594210138.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.linux", - "allow_live_comments": False, - "selftext_html": '<!-- SC_OFF --><div class="md"><p>Welcome to <a href="/r/linux">r/linux</a>! If you&#39;re new to Linux or trying to get started this thread is for you. Get help here or as always, check out <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a></p>\n\n<p>This megathread is for all your question needs. As we don&#39;t allow questions on <a href="/r/linux">r/linux</a> outside of this megathread, please consider using <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a> for the best solution to your problem.</p>\n\n<p>Ask your hardware requests here too or try <a href="/r/linuxhardware">r/linuxhardware</a>!</p>\n</div><!-- SC_ON -->', - "likes": None, - "suggested_sort": "new", - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": True, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": "moderator", - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hna75r", - "is_robot_indexable": True, - "report_reasons": None, - "author": "AutoModerator", - "discussion_type": None, - "num_comments": 80, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", - "parent_whitelist_status": "all_ads", - "stickied": True, - "url": "https://www.reddit.com/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", - "subreddit_subscribers": 544037, - "created_utc": 1594181338.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - ], - "after": "t3_hmytic", - "before": None, - }, -} diff --git a/src/newsreader/news/collection/tests/reddit/builder/tests.py b/src/newsreader/news/collection/tests/reddit/builder/tests.py deleted file mode 100644 index 5144edf..0000000 --- a/src/newsreader/news/collection/tests/reddit/builder/tests.py +++ /dev/null @@ -1,472 +0,0 @@ -from datetime import datetime, timezone -from unittest.mock import Mock - -from django.test import TestCase - -from newsreader.news.collection.reddit import RedditBuilder -from newsreader.news.collection.tests.factories import SubredditFactory -from newsreader.news.collection.tests.reddit.builder.mocks import ( - author_mock, - comment_mock, - downvote_mock, - duplicate_mock, - empty_mock, - external_gifv_mock, - external_image_mock, - external_video_mock, - image_mock, - nsfw_mock, - seen_mock, - simple_mock, - spoiler_mock, - title_mock, - unknown_mock, - unsanitized_mock, - upvote_mock, - video_mock, -) -from newsreader.news.core.models import Post -from newsreader.news.core.tests.factories import RedditPostFactory - - -class RedditBuilderTestCase(TestCase): - def setUp(self): - self.maxDiff = None - - def test_simple_mock(self): - builder = RedditBuilder - - subreddit = SubredditFactory() - mock_stream = Mock(rule=subreddit) - - with builder(simple_mock, mock_stream) as builder: - builder.build() - builder.save() - - posts = {post.remote_identifier: post for post in Post.objects.all()} - - self.assertCountEqual( - ("hm0qct", "hna75r", "hngs71", "hngsj8", "hnd7cy"), posts.keys() - ) - - post = posts["hm0qct"] - - self.assertEqual(post.rule, subreddit) - self.assertEqual( - post.title, - "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", - ) - self.assertIn( - " This megathread is also to hear opinions from anyone just starting out" - " with Linux or those that have used Linux (GNU or otherwise) for a long", - post.body, - ) - - self.assertIn( - "

      For those looking for certifications please use this megathread to ask about how" - " to get certified whether it's for the business world or for your own satisfaction." - ' Be sure to check out r/linuxadmin for more discussion in the' - " SysAdmin world!

      ", - post.body, - ) - - self.assertEqual(post.author, "AutoModerator") - self.assertEqual( - post.url, - "https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", - ) - self.assertEqual( - post.publication_date, datetime(2020, 7, 6, 6, 11, 22, tzinfo=timezone.utc) - ) - - def test_empty_data(self): - builder = RedditBuilder - - subreddit = SubredditFactory() - mock_stream = Mock(rule=subreddit) - - with builder(empty_mock, mock_stream) as builder: - builder.build() - builder.save() - - self.assertEqual(Post.objects.count(), 0) - - def test_unknown_mock(self): - builder = RedditBuilder - - subreddit = SubredditFactory() - mock_stream = Mock(rule=subreddit) - - with builder(unknown_mock, mock_stream) as builder: - builder.build() - builder.save() - - self.assertEqual(Post.objects.count(), 0) - - def test_html_sanitizing(self): - builder = RedditBuilder - - subreddit = SubredditFactory() - mock_stream = Mock(rule=subreddit) - - with builder(unsanitized_mock, mock_stream) as builder: - builder.build() - builder.save() - - posts = {post.remote_identifier: post for post in Post.objects.all()} - - self.assertCountEqual(("hnd7cy",), posts.keys()) - - post = posts["hnd7cy"] - - self.assertEqual(post.body, "
      ") - - def test_long_author_text_is_truncated(self): - builder = RedditBuilder - - subreddit = SubredditFactory() - mock_stream = Mock(rule=subreddit) - - with builder(author_mock, mock_stream) as builder: - builder.build() - builder.save() - - posts = {post.remote_identifier: post for post in Post.objects.all()} - - self.assertCountEqual(("hnd7cy",), posts.keys()) - - post = posts["hnd7cy"] - - self.assertEqual(post.author, "TheQuantumZeroTheQuantumZeroTheQuantumZ…") - - def test_long_title_text_is_truncated(self): - builder = RedditBuilder - - subreddit = SubredditFactory() - mock_stream = Mock(rule=subreddit) - - with builder(title_mock, mock_stream) as builder: - builder.build() - builder.save() - - posts = {post.remote_identifier: post for post in Post.objects.all()} - - self.assertCountEqual(("hnd7cy",), posts.keys()) - - post = posts["hnd7cy"] - - self.assertEqual( - post.title, - 'Board statement on the LibreOffice 7.0 RC "Personal EditionBoard statement on the LibreOffice 7.0 RC "Personal Edition" label" labelBoard statement on the LibreOffice 7.0 RC "PersBoard statement on t…', - ) - - def test_duplicate_in_response(self): - builder = RedditBuilder - - subreddit = SubredditFactory() - mock_stream = Mock(rule=subreddit) - - with builder(duplicate_mock, mock_stream) as builder: - builder.build() - builder.save() - - posts = {post.remote_identifier: post for post in Post.objects.all()} - - self.assertEqual(Post.objects.count(), 2) - self.assertCountEqual(("hm0qct", "hna75r"), posts.keys()) - - def test_duplicate_in_database(self): - builder = RedditBuilder - - subreddit = SubredditFactory() - mock_stream = Mock(rule=subreddit) - - RedditPostFactory(remote_identifier="hm0qct", rule=subreddit, title="foo") - - with builder(simple_mock, mock_stream) as builder: - builder.build() - builder.save() - - posts = {post.remote_identifier: post for post in Post.objects.all()} - - self.assertEqual(Post.objects.count(), 5) - self.assertCountEqual( - ("hm0qct", "hna75r", "hngs71", "hngsj8", "hnd7cy"), posts.keys() - ) - - def test_image_post(self): - builder = RedditBuilder - - subreddit = SubredditFactory() - mock_stream = Mock(rule=subreddit) - - with builder(image_mock, mock_stream) as builder: - builder.build() - builder.save() - - posts = {post.remote_identifier: post for post in Post.objects.all()} - - self.assertCountEqual(("hr64xh", "hr4bxo", "hr14y5", "hr2fv0"), posts.keys()) - - post = posts["hr64xh"] - - title = ( - "Ya’ll, I just can’t... this is my " - "son, Judah. My wife and I have no " - "idea how we created such a " - "beautiful child." - ) - url = "https://i.redd.it/cm2qybia1va51.jpg" - - self.assertEqual( - "https://www.reddit.com/r/aww/comments/hr64xh/yall_i_just_cant_this_is_my_son_judah_my_wife_and/", - post.url, - ) - self.assertEqual( - f"
      {title}
      ", post.body - ) - - def test_external_image_post(self): - builder = RedditBuilder - - subreddit = SubredditFactory() - mock_stream = Mock(rule=subreddit) - - with builder(external_image_mock, mock_stream) as builder: - builder.build() - builder.save() - - posts = {post.remote_identifier: post for post in Post.objects.all()} - - self.assertCountEqual(("hr41am", "huoldn"), posts.keys()) - - post = posts["hr41am"] - - url = "http://gfycat.com/thatalivedogwoodclubgall" - title = "Excited cows have a new brush!" - - self.assertEqual( - f"", - post.body, - ) - self.assertEqual( - "https://www.reddit.com/r/aww/comments/hr41am/excited_cows_have_a_new_brush/", - post.url, - ) - - post = posts["huoldn"] - - url = "https://i.imgur.com/usfMVUJ.jpg" - title = "Novosibirsk Zoo welcomes 16 cobalt-eyed Pallas’s cat kittens" - - self.assertEqual( - f"
      {title}
      ", post.body - ) - self.assertEqual( - "https://www.reddit.com/r/aww/comments/huoldn/novosibirsk_zoo_welcomes_16_cobalteyed_pallass/", - post.url, - ) - - def test_video_post(self): - builder = RedditBuilder - - subreddit = SubredditFactory() - mock_stream = Mock(rule=subreddit) - - with builder(video_mock, mock_stream) as builder: - builder.build() - builder.save() - - posts = {post.remote_identifier: post for post in Post.objects.all()} - - self.assertCountEqual(("hr32jf", "hr1r00", "hqy0ny", "hr0uzh"), posts.keys()) - - post = posts["hr1r00"] - - url = "https://v.redd.it/eyvbxaeqtta51/DASH_480.mp4?source=fallback" - - self.assertEqual( - post.url, - "https://www.reddit.com/r/aww/comments/hr1r00/cool_catt_and_his_clingy_girlfriend/", - ) - self.assertEqual( - f"
      ", - post.body, - ) - - def test_external_video_post(self): - builder = RedditBuilder - - subreddit = SubredditFactory() - mock_stream = Mock(rule=subreddit) - - with builder(external_video_mock, mock_stream) as builder: - builder.build() - builder.save() - - post = Post.objects.get() - - self.assertEqual(post.remote_identifier, "hulh8k") - - self.assertEqual( - post.url, - "https://www.reddit.com/r/aww/comments/hulh8k/dog_splashing_in_water/", - ) - - title = "Dog splashing in water" - url = "https://gfycat.com/excellentinfantileamericanwigeon" - - self.assertEqual( - f"", - post.body, - ) - - def test_external_gifv_video_post(self): - builder = RedditBuilder - - subreddit = SubredditFactory() - mock_stream = Mock(rule=subreddit) - - with builder(external_gifv_mock, mock_stream) as builder: - builder.build() - builder.save() - - post = Post.objects.get() - - self.assertEqual(post.remote_identifier, "humdlf") - - self.assertEqual( - post.url, "https://www.reddit.com/r/aww/comments/humdlf/if_i_fits_i_sits/" - ) - - self.assertEqual( - "
      ", - post.body, - ) - - def test_link_only_post(self): - builder = RedditBuilder - - subreddit = SubredditFactory() - mock_stream = Mock(rule=subreddit) - - with builder(simple_mock, mock_stream) as builder: - builder.build() - builder.save() - - post = Post.objects.get(remote_identifier="hngsj8") - - title = "KeePassXC 2.6.0 released" - url = "https://keepassxc.org/blog/2020-07-07-2.6.0-released/" - - self.assertIn( - f"", - post.body, - ) - - self.assertEqual( - post.url, - "https://www.reddit.com/r/linux/comments/hngsj8/keepassxc_260_released/", - ) - - def test_skip_not_known_post_type(self): - builder = RedditBuilder - - subreddit = SubredditFactory() - mock_stream = Mock(rule=subreddit) - - with builder(unknown_mock, mock_stream) as builder: - builder.build() - builder.save() - - self.assertEqual(Post.objects.count(), 0) - - def test_nsfw_not_allowed(self): - builder = RedditBuilder - - subreddit = SubredditFactory(reddit_allow_nfsw=False) - mock_stream = Mock(rule=subreddit) - - with builder(nsfw_mock, mock_stream) as builder: - builder.build() - builder.save() - - posts = {post.remote_identifier: post for post in Post.objects.all()} - - self.assertEqual(Post.objects.count(), 1) - self.assertCountEqual(("hna75r",), posts.keys()) - - def test_spoiler_not_allowed(self): - builder = RedditBuilder - - subreddit = SubredditFactory(reddit_allow_spoiler=False) - mock_stream = Mock(rule=subreddit) - - with builder(spoiler_mock, mock_stream) as builder: - builder.build() - builder.save() - - posts = {post.remote_identifier: post for post in Post.objects.all()} - - self.assertEqual(Post.objects.count(), 1) - self.assertCountEqual(("hm0qct",), posts.keys()) - - def test_already_seen_not_allowed(self): - builder = RedditBuilder - - subreddit = SubredditFactory(reddit_allow_viewed=False) - mock_stream = Mock(rule=subreddit) - - with builder(seen_mock, mock_stream) as builder: - builder.build() - builder.save() - - posts = {post.remote_identifier: post for post in Post.objects.all()} - - self.assertEqual(Post.objects.count(), 1) - self.assertCountEqual(("hna75r",), posts.keys()) - - def test_upvote_minimum(self): - builder = RedditBuilder - - subreddit = SubredditFactory(reddit_upvotes_min=100) - mock_stream = Mock(rule=subreddit) - - with builder(upvote_mock, mock_stream) as builder: - builder.build() - builder.save() - - posts = {post.remote_identifier: post for post in Post.objects.all()} - - self.assertEqual(Post.objects.count(), 1) - self.assertCountEqual(("hna75r",), posts.keys()) - - def test_comments_minimum(self): - builder = RedditBuilder - - subreddit = SubredditFactory(reddit_comments_min=100) - mock_stream = Mock(rule=subreddit) - - with builder(comment_mock, mock_stream) as builder: - builder.build() - builder.save() - - posts = {post.remote_identifier: post for post in Post.objects.all()} - - self.assertEqual(Post.objects.count(), 1) - self.assertCountEqual(("hm0qct",), posts.keys()) - - def test_downvote_maximum(self): - builder = RedditBuilder - - subreddit = SubredditFactory(reddit_downvotes_max=20) - mock_stream = Mock(rule=subreddit) - - with builder(downvote_mock, mock_stream) as builder: - builder.build() - builder.save() - - posts = {post.remote_identifier: post for post in Post.objects.all()} - - self.assertEqual(Post.objects.count(), 1) - self.assertCountEqual(("hm0qct",), posts.keys()) diff --git a/src/newsreader/news/collection/tests/reddit/client/__init__.py b/src/newsreader/news/collection/tests/reddit/client/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/newsreader/news/collection/tests/reddit/client/mocks.py b/src/newsreader/news/collection/tests/reddit/client/mocks.py deleted file mode 100644 index 6a11409..0000000 --- a/src/newsreader/news/collection/tests/reddit/client/mocks.py +++ /dev/null @@ -1,160 +0,0 @@ -# Note that some response data is truncated - -simple_mock = { - "data": { - "after": "t3_hjywyf", - "before": None, - "children": [ - { - "data": { - "approved_at_utc": None, - "approved_by": None, - "archived": False, - "author": "AutoModerator", - "banned_at_utc": None, - "banned_by": None, - "category": None, - "content_categories": None, - "created": 1593605471.0, - "created_utc": 1593576671.0, - "discussion_type": None, - "distinguished": "moderator", - "domain": "self.linux", - "edited": False, - "hidden": False, - "id": "hj34ck", - "locked": False, - "name": "t3_hj34ck", - "permalink": "/r/linux/comments/hj34ck/weekly_questions_and_hardware_thread_july_01_2020/", - "pinned": False, - "selftext": "Welcome to r/linux! If you're " - "new to Linux or trying to get " - "started this thread is for you. " - "Get help here or as always, " - "check out r/linuxquestions or " - "r/linux4noobs\n" - "\n" - "This megathread is for all your " - "question needs. As we don't " - "allow questions on r/linux " - "outside of this megathread, " - "please consider using " - "r/linuxquestions or " - "r/linux4noobs for the best " - "solution to your problem.\n" - "\n" - "Ask your hardware requests here " - "too or try r/linuxhardware!", - "selftext_html": "<!-- SC_OFF " - "--><div " - 'class="md"><p>Welcome ' - "to <a " - 'href="/r/linux">r/linux</a>! ' - "If you&#39;re new to " - "Linux or trying to get " - "started this thread is for " - "you. Get help here or as " - "always, check out <a " - 'href="/r/linuxquestions">r/linuxquestions</a> ' - "or <a " - 'href="/r/linux4noobs">r/linux4noobs</a></p>\n' - "\n" - "<p>This megathread is " - "for all your question " - "needs. As we don&#39;t " - "allow questions on <a " - 'href="/r/linux">r/linux</a> ' - "outside of this megathread, " - "please consider using <a " - 'href="/r/linuxquestions">r/linuxquestions</a> ' - "or <a " - 'href="/r/linux4noobs">r/linux4noobs</a> ' - "for the best solution to " - "your problem.</p>\n" - "\n" - "<p>Ask your hardware " - "requests here too or try " - "<a " - 'href="/r/linuxhardware">r/linuxhardware</a>!</p>\n' - "</div><!-- SC_ON " - "-->", - "spoiler": False, - "stickied": True, - "subreddit": "linux", - "subreddit_id": "t5_2qh1a", - "subreddit_name_prefixed": "r/linux", - "title": "Weekly Questions and Hardware " "Thread - July 01, 2020", - "url": "https://www.reddit.com/r/linux/comments/hj34ck/weekly_questions_and_hardware_thread_july_01_2020/", - "visited": False, - }, - "kind": "t3", - }, - { - "data": { - "archived": False, - "author": "AutoModerator", - "banned_at_utc": None, - "banned_by": None, - "category": None, - "created": 1593824903.0, - "created_utc": 1593796103.0, - "discussion_type": None, - "domain": "self.linux", - "edited": False, - "hidden": False, - "id": "hkmu0t", - "name": "t3_hkmu0t", - "permalink": "/r/linux/comments/hkmu0t/weekend_fluff_linux_in_the_wild_thread_july_03/", - "pinned": False, - "saved": False, - "selftext": "Welcome to the weekend! This " - "stickied thread is for you to " - "post pictures of your ubuntu " - "2006 install disk, slackware " - "floppies, on-topic memes or " - "more.\n" - "\n" - "When it's not the weekend, be " - "sure to check out " - "r/WildLinuxAppears or " - "r/linuxmemes!", - "selftext_html": "<!-- SC_OFF " - "--><div " - 'class="md"><p>Welcome ' - "to the weekend! This " - "stickied thread is for you " - "to post pictures of your " - "ubuntu 2006 install disk, " - "slackware floppies, " - "on-topic memes or " - "more.</p>\n" - "\n" - "<p>When it&#39;s " - "not the weekend, be sure to " - "check out <a " - 'href="/r/WildLinuxAppears">r/WildLinuxAppears</a> ' - "or <a " - 'href="/r/linuxmemes">r/linuxmemes</a>!</p>\n' - "</div><!-- SC_ON " - "-->", - "spoiler": False, - "stickied": True, - "subreddit": "linux", - "subreddit_id": "t5_2qh1a", - "subreddit_name_prefixed": "r/linux", - "subreddit_subscribers": 542073, - "subreddit_type": "public", - "thumbnail": "", - "title": "Weekend Fluff / Linux in the Wild " - "Thread - July 03, 2020", - "url": "https://www.reddit.com/r/linux/comments/hkmu0t/weekend_fluff_linux_in_the_wild_thread_july_03/", - "visited": False, - }, - "kind": "t3", - }, - ], - "dist": 27, - "modhash": None, - }, - "kind": "Listing", -} diff --git a/src/newsreader/news/collection/tests/reddit/client/tests.py b/src/newsreader/news/collection/tests/reddit/client/tests.py deleted file mode 100644 index a334346..0000000 --- a/src/newsreader/news/collection/tests/reddit/client/tests.py +++ /dev/null @@ -1,163 +0,0 @@ -from unittest.mock import Mock, patch -from uuid import uuid4 - -from django.test import TestCase -from django.utils.lorem_ipsum import words - -from newsreader.accounts.tests.factories import UserFactory -from newsreader.news.collection.exceptions import ( - StreamDeniedException, - StreamException, - StreamNotFoundException, - StreamParseException, - StreamTimeOutException, - StreamTooManyException, -) -from newsreader.news.collection.reddit import RedditClient -from newsreader.news.collection.tests.factories import SubredditFactory - -from .mocks import simple_mock - - -class RedditClientTestCase(TestCase): - def setUp(self): - self.maxDiff = None - - self.patched_read = patch("newsreader.news.collection.reddit.RedditStream.read") - self.mocked_read = self.patched_read.start() - - def tearDown(self): - patch.stopall() - - def test_client_retrieves_single_rules(self): - subreddit = SubredditFactory() - mock_stream = Mock(rule=subreddit) - - self.mocked_read.return_value = (simple_mock, mock_stream) - - with RedditClient([[subreddit]]) as client: - for data, stream in client: - with self.subTest(data=data, stream=stream): - self.assertEquals(data, simple_mock) - self.assertEquals(stream, mock_stream) - - self.mocked_read.assert_called_once_with() - - def test_client_catches_stream_exception(self): - subreddit = SubredditFactory() - - self.mocked_read.side_effect = StreamException(message="Stream exception") - - with RedditClient([[subreddit]]) as client: - for data, stream in client: - with self.subTest(data=data, stream=stream): - self.assertEquals(data, None) - self.assertEquals(stream, None) - self.assertEquals(stream.rule.error, "Stream exception") - self.assertEquals(stream.rule.succeeded, False) - - self.mocked_read.assert_called_once_with() - - def test_client_catches_stream_not_found_exception(self): - subreddit = SubredditFactory.create() - - self.mocked_read.side_effect = StreamNotFoundException( - message="Stream not found" - ) - - with RedditClient([[subreddit]]) as client: - for data, stream in client: - with self.subTest(data=data, stream=stream): - self.assertEquals(data, None) - self.assertEquals(stream, None) - self.assertEquals(stream.rule.error, "Stream not found") - self.assertEquals(stream.rule.succeeded, False) - - self.mocked_read.assert_called_once_with() - - @patch("newsreader.news.collection.reddit.RedditTokenTask") - def test_client_catches_stream_denied_exception(self, mocked_task): - user = UserFactory( - reddit_access_token=str(uuid4()), reddit_refresh_token=str(uuid4()) - ) - subreddit = SubredditFactory(user=user) - - self.mocked_read.side_effect = StreamDeniedException(message="Token expired") - - with RedditClient([(subreddit,)]) as client: - results = [(data, stream) for data, stream in client] - - self.mocked_read.assert_called_once_with() - mocked_task.delay.assert_called_once_with(user.pk) - - self.assertEquals(len(results), 0) - - user.refresh_from_db() - subreddit.refresh_from_db() - - self.assertEquals(user.reddit_access_token, None) - self.assertEquals(subreddit.succeeded, False) - self.assertEquals(subreddit.error, "Token expired") - - def test_client_catches_stream_timed_out_exception(self): - subreddit = SubredditFactory() - - self.mocked_read.side_effect = StreamTimeOutException( - message="Stream timed out" - ) - - with RedditClient([[subreddit]]) as client: - for data, stream in client: - with self.subTest(data=data, stream=stream): - self.assertEquals(data, None) - self.assertEquals(stream, None) - self.assertEquals(stream.rule.error, "Stream timed out") - self.assertEquals(stream.rule.succeeded, False) - - self.mocked_read.assert_called_once_with() - - def test_client_catches_stream_too_many_exception(self): - subreddit = SubredditFactory() - - self.mocked_read.side_effect = StreamTooManyException - - with RedditClient([[subreddit]]) as client: - for data, stream in client: - with self.subTest(data=data, stream=stream): - self.assertEquals(data, None) - self.assertEquals(stream, None) - self.assertEquals(stream.rule.error, "Too many requests") - self.assertEquals(stream.rule.succeeded, False) - - self.mocked_read.assert_called_once_with() - - def test_client_catches_stream_parse_exception(self): - subreddit = SubredditFactory() - - self.mocked_read.side_effect = StreamParseException( - message="Stream could not be parsed" - ) - - with RedditClient([[subreddit]]) as client: - for data, stream in client: - with self.subTest(data=data, stream=stream): - self.assertEquals(data, None) - self.assertEquals(stream, None) - self.assertEquals(stream.rule.error, "Stream could not be parsed") - self.assertEquals(stream.rule.succeeded, False) - - self.mocked_read.assert_called_once_with() - - def test_client_catches_long_exception_text(self): - subreddit = SubredditFactory() - - self.mocked_read.side_effect = StreamParseException(message=words(1000)) - - with RedditClient([[subreddit]]) as client: - for data, stream in client: - self.assertEquals(data, None) - self.assertEquals(stream, None) - self.assertEquals(len(stream.rule.error), 1024) - self.assertEquals(stream.rule.succeeded, False) - - self.mocked_read.assert_called_once_with() diff --git a/src/newsreader/news/collection/tests/reddit/collector/__init__.py b/src/newsreader/news/collection/tests/reddit/collector/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/newsreader/news/collection/tests/reddit/collector/mocks.py b/src/newsreader/news/collection/tests/reddit/collector/mocks.py deleted file mode 100644 index 37d40d8..0000000 --- a/src/newsreader/news/collection/tests/reddit/collector/mocks.py +++ /dev/null @@ -1,1662 +0,0 @@ -simple_mock_1 = { - "kind": "Listing", - "data": { - "modhash": "khwcr8tmp613f1b92d55150adb744983e7f6c37e87e30f6432", - "dist": 26, - "children": [ - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "starcitizen", - "selftext": "Welcome to the Star Citizen question and answer thread. Feel free to ask any questions you have related to SC here!\r\n\r\n---\r\n\r\nUseful Links and Resources:\r\n\r\n[Star Citizen Wiki](https://starcitizen.tools) - *The biggest and best wiki resource dedicated to Star Citizen*\r\n\r\n[Star Citizen FAQ](https://starcitizen.tools/Frequently_Asked_Questions) - *Chances the answer you need is here.* \r\n\r\n[Discord Help Channel](https://discord.gg/0STCP5tSe7x9NBSq) - *Often times community members will be here to help you with issues.*\r\n\r\n[Referral Code Randomizer](http://gorefer.me/starcitizen) - *Use this when creating a new account to get 5000 extra UEC.*\r\n\r\n[Download Star Citizen](https://robertsspaceindustries.com/download) - *Get the latest version of Star Citizen here*\r\n\r\n[Current Game Features](https://robertsspaceindustries.com/feature-list) - *Click here to see what you can currently do in Star Citizen.*\r\n\r\n[Development Roadmap](https://robertsspaceindustries.com/roadmap/board/1-Star-Citizen) - *The current development status of up and coming Star Citizen features.*\r\n\r\n[Pledge FAQ](https://support.robertsspaceindustries.com/hc/en-us/articles/115013194987-Pledges-FAQs) - *Official FAQ regarding spending money on the game.*", - "author_fullname": "t2_otk50", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Star Citizen: Question and Answer Thread", - "link_flair_richtext": [{"e": "text", "t": "QUESTION"}], - "subreddit_name_prefixed": "r/starcitizen", - "hidden": False, - "pwls": 6, - "link_flair_css_class": "QUESTION", - "downs": 0, - "thumbnail_height": None, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hm6byg", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.9, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 21, - "total_awards_received": 0, - "media_embed": {}, - "thumbnail_width": None, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "QUESTION", - "can_mod_post": False, - "score": 21, - "approved_by": None, - "author_premium": False, - "thumbnail": "self", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "post_hint": "self", - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594065605, - "link_flair_type": "richtext", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.starcitizen", - "allow_live_comments": False, - "selftext_html": '<!-- SC_OFF --><div class="md"><p>Welcome to the Star Citizen question and answer thread. Feel free to ask any questions you have related to SC here!</p>\n\n<hr/>\n\n<p>Useful Links and Resources:</p>\n\n<p><a href="https://starcitizen.tools">Star Citizen Wiki</a> - <em>The biggest and best wiki resource dedicated to Star Citizen</em></p>\n\n<p><a href="https://starcitizen.tools/Frequently_Asked_Questions">Star Citizen FAQ</a> - <em>Chances the answer you need is here.</em> </p>\n\n<p><a href="https://discord.gg/0STCP5tSe7x9NBSq">Discord Help Channel</a> - <em>Often times community members will be here to help you with issues.</em></p>\n\n<p><a href="http://gorefer.me/starcitizen">Referral Code Randomizer</a> - <em>Use this when creating a new account to get 5000 extra UEC.</em></p>\n\n<p><a href="https://robertsspaceindustries.com/download">Download Star Citizen</a> - <em>Get the latest version of Star Citizen here</em></p>\n\n<p><a href="https://robertsspaceindustries.com/feature-list">Current Game Features</a> - <em>Click here to see what you can currently do in Star Citizen.</em></p>\n\n<p><a href="https://robertsspaceindustries.com/roadmap/board/1-Star-Citizen">Development Roadmap</a> - <em>The current development status of up and coming Star Citizen features.</em></p>\n\n<p><a href="https://support.robertsspaceindustries.com/hc/en-us/articles/115013194987-Pledges-FAQs">Pledge FAQ</a> - <em>Official FAQ regarding spending money on the game.</em></p>\n</div><!-- SC_ON -->', - "likes": None, - "suggested_sort": "new", - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "preview": { - "images": [ - { - "source": { - "url": "https://external-preview.redd.it/5qLIX6ObbErxkDiTz24uP6TbZGNVNyy2SeXusPhzaKA.jpg?auto=webp&s=738b5270a81373916191470a1da34cdcc54d8511", - "width": 332, - "height": 360, - }, - "resolutions": [ - { - "url": "https://external-preview.redd.it/5qLIX6ObbErxkDiTz24uP6TbZGNVNyy2SeXusPhzaKA.jpg?width=108&crop=smart&auto=webp&s=e2ee2a9dae15472663b52c8cb4e002fdbbb6378c", - "width": 108, - "height": 117, - }, - { - "url": "https://external-preview.redd.it/5qLIX6ObbErxkDiTz24uP6TbZGNVNyy2SeXusPhzaKA.jpg?width=216&crop=smart&auto=webp&s=3690c60a9b533d376f159f306c6667b47ff42102", - "width": 216, - "height": 234, - }, - { - "url": "https://external-preview.redd.it/5qLIX6ObbErxkDiTz24uP6TbZGNVNyy2SeXusPhzaKA.jpg?width=320&crop=smart&auto=webp&s=4dcb434a5071329ecbb9f3543e4d06442ab141df", - "width": 320, - "height": 346, - }, - ], - "variants": {}, - "id": "KTE3H6RnWCasOJCFtdmgmw51FMzxSqXz_SRD6W5Rdsc", - } - ], - "enabled": False, - }, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2v94d", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hm6byg", - "is_robot_indexable": True, - "report_reasons": None, - "author": "UEE_Central_Computer", - "discussion_type": None, - "num_comments": 380, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/starcitizen/comments/hm6byg/star_citizen_question_and_answer_thread/", - "parent_whitelist_status": "all_ads", - "stickied": True, - "url": "https://www.reddit.com/r/starcitizen/comments/hm6byg/star_citizen_question_and_answer_thread/", - "subreddit_subscribers": 213071, - "created_utc": 1594036805, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "starcitizen", - "selftext": "", - "author_fullname": "t2_6wgp9w28", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "5 random people in a train felt like such a rare and special thing 😁", - "link_flair_richtext": [{"e": "text", "t": "FLUFF"}], - "subreddit_name_prefixed": "r/starcitizen", - "hidden": False, - "pwls": 6, - "link_flair_css_class": "fluff", - "downs": 0, - "thumbnail_height": 78, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hpkhgj", - "quarantine": False, - "link_flair_text_color": "light", - "upvote_ratio": 0.98, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 892, - "total_awards_received": 0, - "media_embed": {}, - "thumbnail_width": 140, - "author_flair_template_id": "a87724f8-c2b5-11e4-b7e0-22000b2103f6", - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": True, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "FLUFF", - "can_mod_post": False, - "score": 892, - "approved_by": None, - "author_premium": False, - "thumbnail": "https://b.thumbs.redditmedia.com/YlF6BTm-DfnrZBeukYiOyrP-Fkj2xUQtk_V8ZeUD93w.jpg", - "edited": False, - "author_flair_css_class": "aurora", - "author_flair_richtext": [ - {"e": "text", "t": "🌌2013Backer🎮vGameDev🌌"} - ], - "gildings": {}, - "post_hint": "image", - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594540209, - "link_flair_type": "richtext", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "richtext", - "domain": "i.redd.it", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://i.redd.it/0jkge020fba51.png", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "preview": { - "images": [ - { - "source": { - "url": "https://preview.redd.it/0jkge020fba51.png?auto=webp&s=c3a2b8cb860f839638a364d49abca04fd4f42094", - "width": 2560, - "height": 1440, - }, - "resolutions": [ - { - "url": "https://preview.redd.it/0jkge020fba51.png?width=108&crop=smart&auto=webp&s=778a7f7d9b2e0d713161e84b32c467ebde6cbc17", - "width": 108, - "height": 60, - }, - { - "url": "https://preview.redd.it/0jkge020fba51.png?width=216&crop=smart&auto=webp&s=53afc50cc2dd6c72470e76a4c3ff8ef597f66e0d", - "width": 216, - "height": 121, - }, - { - "url": "https://preview.redd.it/0jkge020fba51.png?width=320&crop=smart&auto=webp&s=089f9ff42e429b5062c143695e695cbb4ea5b679", - "width": 320, - "height": 180, - }, - { - "url": "https://preview.redd.it/0jkge020fba51.png?width=640&crop=smart&auto=webp&s=045327ac6fd113630c0faef426d86efaf04f55e2", - "width": 640, - "height": 360, - }, - { - "url": "https://preview.redd.it/0jkge020fba51.png?width=960&crop=smart&auto=webp&s=efbdc9ddcda1207fafa20bb45e82fbe24ed37df8", - "width": 960, - "height": 540, - }, - { - "url": "https://preview.redd.it/0jkge020fba51.png?width=1080&crop=smart&auto=webp&s=1b94c9951c60a788357dfa0fe21dd983efdcf1e7", - "width": 1080, - "height": 607, - }, - ], - "variants": {}, - "id": "r-JjrJn0RtZLaxMk_d-TCfW80pWgJ-5kjMaje54J5_I", - } - ], - "enabled": True, - }, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "db099dc4-3538-11e5-97ec-0e7f0fa558f9", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": "🌌2013Backer🎮vGameDev🌌", - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2v94d", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "#007373", - "id": "hpkhgj", - "is_robot_indexable": True, - "report_reasons": None, - "author": "Y_DK_Y", - "discussion_type": None, - "num_comments": 39, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": "dark", - "permalink": "/r/starcitizen/comments/hpkhgj/5_random_people_in_a_train_felt_like_such_a_rare/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://i.redd.it/0jkge020fba51.png", - "subreddit_subscribers": 213071, - "created_utc": 1594511409, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "starcitizen", - "selftext": "", - "author_fullname": "t2_4brylpu5", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Drake Interplanetary Smartkey thing that I made!", - "link_flair_richtext": [{"e": "text", "t": "ARTWORK"}], - "subreddit_name_prefixed": "r/starcitizen", - "hidden": False, - "pwls": 6, - "link_flair_css_class": "artwork", - "downs": 0, - "thumbnail_height": 78, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hph00n", - "quarantine": False, - "link_flair_text_color": "light", - "upvote_ratio": 0.97, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 547, - "total_awards_received": 1, - "media_embed": {}, - "thumbnail_width": 140, - "author_flair_template_id": None, - "is_original_content": True, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": True, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "ARTWORK", - "can_mod_post": False, - "score": 547, - "approved_by": None, - "author_premium": False, - "thumbnail": "https://b.thumbs.redditmedia.com/gr7RYEjNN5FNc42LxuizFW_ZxWtS3xbZj1QfhIa-2Hw.jpg", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "post_hint": "image", - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594527804, - "link_flair_type": "richtext", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "i.redd.it", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://i.redd.it/b6h74eljeaa51.png", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "preview": { - "images": [ - { - "source": { - "url": "https://preview.redd.it/b6h74eljeaa51.png?auto=webp&s=fd286c2dcd98378c34fde6e245cf13c357716dca", - "width": 1920, - "height": 1080, - }, - "resolutions": [ - { - "url": "https://preview.redd.it/b6h74eljeaa51.png?width=108&crop=smart&auto=webp&s=3150c2a2643d178eba735cb0bc222b8b29f46c8c", - "width": 108, - "height": 60, - }, - { - "url": "https://preview.redd.it/b6h74eljeaa51.png?width=216&crop=smart&auto=webp&s=9120ce40ce7439ca4d3431da7782a8c6acd2eebf", - "width": 216, - "height": 121, - }, - { - "url": "https://preview.redd.it/b6h74eljeaa51.png?width=320&crop=smart&auto=webp&s=83cd5c93fe7a19e5643df38eec3aefee54912faf", - "width": 320, - "height": 180, - }, - { - "url": "https://preview.redd.it/b6h74eljeaa51.png?width=640&crop=smart&auto=webp&s=b3e280a4a7fbaf794692c01f4ff63af0b8559700", - "width": 640, - "height": 360, - }, - { - "url": "https://preview.redd.it/b6h74eljeaa51.png?width=960&crop=smart&auto=webp&s=8ebac203688ba0e42c7975f3d7688dab25fc065b", - "width": 960, - "height": 540, - }, - { - "url": "https://preview.redd.it/b6h74eljeaa51.png?width=1080&crop=smart&auto=webp&s=8350e0b4e004820ef9f30501397d49a2121186ec", - "width": 1080, - "height": 607, - }, - ], - "variants": {}, - "id": "B2HxXfFibxKUtHO9eBwT-Bt_VrE870XhC0R5OFA95rI", - } - ], - "enabled": True, - }, - "all_awardings": [ - { - "giver_coin_reward": 0, - "subreddit_id": None, - "is_new": False, - "days_of_drip_extension": 0, - "coin_price": 50, - "id": "award_02d9ab2c-162e-4c01-8438-317a016ed3d9", - "penny_donate": 0, - "award_sub_type": "GLOBAL", - "coin_reward": 0, - "icon_url": "https://i.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png", - "days_of_premium": 0, - "resized_icons": [ - { - "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=16&height=16&auto=webp&s=92e96be1dbd278dc987fbd9acc1bd5078566f254", - "width": 16, - "height": 16, - }, - { - "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=32&height=32&auto=webp&s=83e14655f2b162b295f7d2c7058b9ad94cf8b73c", - "width": 32, - "height": 32, - }, - { - "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=48&height=48&auto=webp&s=83038a4d6181d3c8f5107dbca4ddb735ca6c2231", - "width": 48, - "height": 48, - }, - { - "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=64&height=64&auto=webp&s=3c4e39a7664d799ff50f32e9a3f96c3109d2e266", - "width": 64, - "height": 64, - }, - { - "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=128&height=128&auto=webp&s=390bf9706b8e1a6215716ebcf6363373f125c339", - "width": 128, - "height": 128, - }, - ], - "icon_width": 2048, - "static_icon_width": 2048, - "start_date": None, - "is_enabled": True, - "description": "I'm in this with you.", - "end_date": None, - "subreddit_coin_reward": 0, - "count": 1, - "static_icon_height": 2048, - "name": "Take My Energy", - "resized_static_icons": [ - { - "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=16&height=16&auto=webp&s=92e96be1dbd278dc987fbd9acc1bd5078566f254", - "width": 16, - "height": 16, - }, - { - "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=32&height=32&auto=webp&s=83e14655f2b162b295f7d2c7058b9ad94cf8b73c", - "width": 32, - "height": 32, - }, - { - "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=48&height=48&auto=webp&s=83038a4d6181d3c8f5107dbca4ddb735ca6c2231", - "width": 48, - "height": 48, - }, - { - "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=64&height=64&auto=webp&s=3c4e39a7664d799ff50f32e9a3f96c3109d2e266", - "width": 64, - "height": 64, - }, - { - "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=128&height=128&auto=webp&s=390bf9706b8e1a6215716ebcf6363373f125c339", - "width": 128, - "height": 128, - }, - ], - "icon_format": "PNG", - "icon_height": 2048, - "penny_price": 0, - "award_type": "global", - "static_icon_url": "https://i.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png", - } - ], - "awarders": [], - "media_only": False, - "link_flair_template_id": "e3bb68b2-3538-11e5-bf5a-0e09b4299f63", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2v94d", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "#ff66ac", - "id": "hph00n", - "is_robot_indexable": True, - "report_reasons": None, - "author": "HannahB888", - "discussion_type": None, - "num_comments": 38, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/starcitizen/comments/hph00n/drake_interplanetary_smartkey_thing_that_i_made/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://i.redd.it/b6h74eljeaa51.png", - "subreddit_subscribers": 213071, - "created_utc": 1594499004, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "starcitizen", - "selftext": "", - "author_fullname": "t2_exlc6", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "A Historical Moment for CIG", - "link_flair_richtext": [{"e": "text", "t": "FLUFF"}], - "subreddit_name_prefixed": "r/starcitizen", - "hidden": False, - "pwls": 6, - "link_flair_css_class": "fluff", - "downs": 0, - "thumbnail_height": 37, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hp9mlw", - "quarantine": False, - "link_flair_text_color": "light", - "upvote_ratio": 0.98, - "author_flair_background_color": "", - "subreddit_type": "public", - "ups": 1444, - "total_awards_received": 0, - "media_embed": {}, - "thumbnail_width": 140, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": True, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "FLUFF", - "can_mod_post": False, - "score": 1444, - "approved_by": None, - "author_premium": False, - "thumbnail": "https://b.thumbs.redditmedia.com/YYdiE2x8fsn0ckVJiGCnBzUIOa1DA03ALh3TJuVlZks.jpg", - "edited": False, - "author_flair_css_class": "carrack", - "author_flair_richtext": [{"e": "text", "t": "AHV Artemis"}], - "gildings": {}, - "post_hint": "image", - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594501406, - "link_flair_type": "richtext", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "richtext", - "domain": "i.redd.it", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://i.redd.it/fdh2ujp388a51.png", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "preview": { - "images": [ - { - "source": { - "url": "https://preview.redd.it/fdh2ujp388a51.png?auto=webp&s=605044c2757c1b5ca9060d3ec448090396a2f0dd", - "width": 424, - "height": 114, - }, - "resolutions": [ - { - "url": "https://preview.redd.it/fdh2ujp388a51.png?width=108&crop=smart&auto=webp&s=9789c6b76d45e46645fe2454555bfbd042a39815", - "width": 108, - "height": 29, - }, - { - "url": "https://preview.redd.it/fdh2ujp388a51.png?width=216&crop=smart&auto=webp&s=3f419183835c883f10b1caab3a7ecbec4ebbf3ec", - "width": 216, - "height": 58, - }, - { - "url": "https://preview.redd.it/fdh2ujp388a51.png?width=320&crop=smart&auto=webp&s=695ff914462b5b9bc253ce26f4a51f5f22641148", - "width": 320, - "height": 86, - }, - ], - "variants": {}, - "id": "XWdU5CBWG0-5mOzBRF65OnvZzQm2Btd2ldGMeJ8u_gI", - } - ], - "enabled": True, - }, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "db099dc4-3538-11e5-97ec-0e7f0fa558f9", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": "AHV Artemis", - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2v94d", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "#007373", - "id": "hp9mlw", - "is_robot_indexable": True, - "report_reasons": None, - "author": "sam00197", - "discussion_type": None, - "num_comments": 194, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": "dark", - "permalink": "/r/starcitizen/comments/hp9mlw/a_historical_moment_for_cig/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://i.redd.it/fdh2ujp388a51.png", - "subreddit_subscribers": 213071, - "created_utc": 1594472606, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "starcitizen", - "selftext": "", - "author_fullname": "t2_4dgjlpn7", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "This view. What's your favorite moon?", - "link_flair_richtext": [{"e": "text", "t": "DISCUSSION"}], - "subreddit_name_prefixed": "r/starcitizen", - "hidden": False, - "pwls": 6, - "link_flair_css_class": "discussion", - "downs": 0, - "thumbnail_height": 78, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hpjn8x", - "quarantine": False, - "link_flair_text_color": "light", - "upvote_ratio": 0.96, - "author_flair_background_color": "", - "subreddit_type": "public", - "ups": 182, - "total_awards_received": 0, - "media_embed": {}, - "thumbnail_width": 140, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": True, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "DISCUSSION", - "can_mod_post": False, - "score": 182, - "approved_by": None, - "author_premium": False, - "thumbnail": "https://a.thumbs.redditmedia.com/tKHL_2fn4Zo9FhrtP3UiJlQA7xkMU7-iN0ntJbhfa80.jpg", - "edited": False, - "author_flair_css_class": "", - "author_flair_richtext": [{"e": "text", "t": "new user/low karma"}], - "gildings": {}, - "post_hint": "image", - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594537150, - "link_flair_type": "richtext", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "richtext", - "domain": "i.redd.it", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://i.redd.it/ovly7f9g6ba51.jpg", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "preview": { - "images": [ - { - "source": { - "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?auto=webp&s=d7051e4c713e39c642c583e5e8ada57c9660fa26", - "width": 2560, - "height": 1440, - }, - "resolutions": [ - { - "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?width=108&crop=smart&auto=webp&s=35f6ebe4531c12bc24532f01741bcf8100d954b2", - "width": 108, - "height": 60, - }, - { - "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?width=216&crop=smart&auto=webp&s=a939922e34cf4ff6a82eeb22e71acb816ccc6d7b", - "width": 216, - "height": 121, - }, - { - "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?width=320&crop=smart&auto=webp&s=9796767ed73e04a774d2f1ba8cf3662bbd4195eb", - "width": 320, - "height": 180, - }, - { - "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?width=640&crop=smart&auto=webp&s=37fe4c262b752cb8dac903daf606be8f0ac3b44f", - "width": 640, - "height": 360, - }, - { - "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?width=960&crop=smart&auto=webp&s=305245fd1d352634c86459131b11238fe09f5d2b", - "width": 960, - "height": 540, - }, - { - "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?width=1080&crop=smart&auto=webp&s=e8438e4b666cf616646ffad09c153d120df1f1d9", - "width": 1080, - "height": 607, - }, - ], - "variants": {}, - "id": "SjRqA5h_B55WLnwAlocF6wcxIHZLgGBMpmb5nV1EQ4E", - } - ], - "enabled": True, - }, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "ca858044-1916-11e2-a9b9-12313d168e98", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": "new user/low karma", - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2v94d", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "#014980", - "id": "hpjn8x", - "is_robot_indexable": True, - "report_reasons": None, - "author": "clericanubis", - "discussion_type": None, - "num_comments": 27, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": "dark", - "permalink": "/r/starcitizen/comments/hpjn8x/this_view_whats_your_favorite_moon/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://i.redd.it/ovly7f9g6ba51.jpg", - "subreddit_subscribers": 213071, - "created_utc": 1594508350, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - ], - "after": "t3_hplinp", - "before": None, - }, -} - -simple_mock_2 = { - "kind": "Listing", - "data": { - "modhash": "y4he8gfzh9f892e2bf3094bc06daba2e02288e617fecf555b5", - "dist": 27, - "children": [ - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "Python", - "selftext": "Top Level comments must be **Job Opportunities.**\n\nPlease include **Location** or any other **Requirements** in your comment. If you require people to work on site in San Francisco, *you must note that in your post.* If you require an Engineering degree, *you must note that in your post*.\n\nPlease include as much information as possible.\n\nIf you are looking for jobs, send a PM to the poster.", - "author_fullname": "t2_628u", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "/r/Python Job Board for May, June, July", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/Python", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_gdfaip", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.98, - "author_flair_background_color": "", - "subreddit_type": "public", - "ups": 108, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 108, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": "", - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1588640187, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.Python", - "allow_live_comments": True, - "selftext_html": '<!-- SC_OFF --><div class="md"><p>Top Level comments must be <strong>Job Opportunities.</strong></p>\n\n<p>Please include <strong>Location</strong> or any other <strong>Requirements</strong> in your comment. If you require people to work on site in San Francisco, <em>you must note that in your post.</em> If you require an Engineering degree, <em>you must note that in your post</em>.</p>\n\n<p>Please include as much information as possible.</p>\n\n<p>If you are looking for jobs, send a PM to the poster.</p>\n</div><!-- SC_ON -->', - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": "reticulated", - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh0y", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "gdfaip", - "is_robot_indexable": True, - "report_reasons": None, - "author": "aphoenix", - "discussion_type": None, - "num_comments": 38, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": "dark", - "permalink": "/r/Python/comments/gdfaip/rpython_job_board_for_may_june_july/", - "parent_whitelist_status": "all_ads", - "stickied": True, - "url": "https://www.reddit.com/r/Python/comments/gdfaip/rpython_job_board_for_may_june_july/", - "subreddit_subscribers": 616297, - "created_utc": 1588611387, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "Python", - "selftext": "# EDIT: AMA complete. Huge thanks to the PyCharm Team for holding this!\n\nAs mentioned in the comments you can use code `reddit20202` at [https://www.jetbrains.com/store/redeem/](https://www.jetbrains.com/store/redeem/) to try out PyCharm Professional as a new JetBrains customer!\n\nWe will be joined by members of the PyCharm Developer team from JetBrains to answer all sorts of questions on the PyCharm IDE and the Python language!\n\n[PyCharm](https://www.jetbrains.com/pycharm/) is the professional IDE for Python Developers with over 33% of respondents from the [2019 Python Developers Survey](https://www.jetbrains.com/lp/python-developers-survey-2019/) choosing it as their main editor.\n\nPyCharm features smart autocompletion, on-the-fly error checking and quick fixes as well as PEP8 compliance detection and automatic refactoring.\n\nIf you haven't checked out PyCharm then you definitely should, the Community Edition of PyCharm includes many key features such as the debugger, test runners, intelligent code completion and more!\n\nIf you are looking for a professional IDE for Python then the PyCharm Professional edition adds features such as advanced web development tools and database/SQL support, if you are a student or maintain an open source project make sure to take a look at the generous discounts JetBrains offer for their products!\n\nThe AMA will begin at 16:00 UTC on the 9th of July. Feel free to drop questions below for the PyCharm team to answer!\n\nWe will be joined by:\n\n* Nafiul Islam, u/nafiulislamjb (Developer Advocate for PyCharm)\n* Andrey Vlasovskikh, u/vlasovskikh (PyCharm Team Lead)", - "user_reports": [], - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "AMA with PyCharm team from JetBrains on 9th July @ 16:00 UTC", - "event_start": 1594310400, - "subreddit_name_prefixed": "r/Python", - "hidden": False, - "pwls": 6, - "link_flair_css_class": "editors", - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hmd2ez", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.94, - "author_flair_background_color": "", - "subreddit_type": "public", - "ups": 60, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "author_fullname": "t2_145f96", - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "Editors / IDEs", - "can_mod_post": False, - "score": 60, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": 1594321779, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594088635, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.Python", - "allow_live_comments": False, - "selftext_html": '<!-- SC_OFF --><div class="md"><h1>EDIT: AMA complete. Huge thanks to the PyCharm Team for holding this!</h1>\n\n<p>As mentioned in the comments you can use code <code>reddit20202</code> at <a href="https://www.jetbrains.com/store/redeem/">https://www.jetbrains.com/store/redeem/</a> to try out PyCharm Professional as a new JetBrains customer!</p>\n\n<p>We will be joined by members of the PyCharm Developer team from JetBrains to answer all sorts of questions on the PyCharm IDE and the Python language!</p>\n\n<p><a href="https://www.jetbrains.com/pycharm/">PyCharm</a> is the professional IDE for Python Developers with over 33% of respondents from the <a href="https://www.jetbrains.com/lp/python-developers-survey-2019/">2019 Python Developers Survey</a> choosing it as their main editor.</p>\n\n<p>PyCharm features smart autocompletion, on-the-fly error checking and quick fixes as well as PEP8 compliance detection and automatic refactoring.</p>\n\n<p>If you haven&#39;t checked out PyCharm then you definitely should, the Community Edition of PyCharm includes many key features such as the debugger, test runners, intelligent code completion and more!</p>\n\n<p>If you are looking for a professional IDE for Python then the PyCharm Professional edition adds features such as advanced web development tools and database/SQL support, if you are a student or maintain an open source project make sure to take a look at the generous discounts JetBrains offer for their products!</p>\n\n<p>The AMA will begin at 16:00 UTC on the 9th of July. Feel free to drop questions below for the PyCharm team to answer!</p>\n\n<p>We will be joined by:</p>\n\n<ul>\n<li>Nafiul Islam, <a href="/u/nafiulislamjb">u/nafiulislamjb</a> (Developer Advocate for PyCharm)</li>\n<li>Andrey Vlasovskikh, <a href="/u/vlasovskikh">u/vlasovskikh</a> (PyCharm Team Lead)</li>\n</ul>\n</div><!-- SC_ON -->', - "likes": None, - "suggested_sort": "confidence", - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "49f2747c-4114-11ea-b9fe-0e741fe75651", - "link_flair_richtext": [], - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": "Owner of Python Discord", - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": "moderator", - "subreddit_id": "t5_2qh0y", - "event_end": 1594324800, - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "event_is_live": False, - "id": "hmd2ez", - "is_robot_indexable": True, - "report_reasons": None, - "author": "Im__Joseph", - "discussion_type": None, - "num_comments": 65, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": "dark", - "permalink": "/r/Python/comments/hmd2ez/ama_with_pycharm_team_from_jetbrains_on_9th_july/", - "parent_whitelist_status": "all_ads", - "stickied": True, - "url": "https://www.reddit.com/r/Python/comments/hmd2ez/ama_with_pycharm_team_from_jetbrains_on_9th_july/", - "subreddit_subscribers": 616297, - "created_utc": 1594059835, - "num_crossposts": 2, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "Python", - "selftext": "", - "author_fullname": "t2_woll6", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "I am a medical student, and I recently programmed an open-source eye-tracker for brain research", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/Python", - "hidden": False, - "pwls": 6, - "link_flair_css_class": "made-this", - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hpr28u", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.99, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 439, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": "4cc838b8-3159-11e1-83e4-12313d18ad57", - "is_original_content": False, - "user_reports": [], - "secure_media": { - "reddit_video": { - "fallback_url": "https://v.redd.it/tqzx750wzda51/DASH_360.mp4?source=fallback", - "height": 384, - "width": 512, - "scrubber_media_url": "https://v.redd.it/tqzx750wzda51/DASH_96.mp4", - "dash_url": "https://v.redd.it/tqzx750wzda51/DASHPlaylist.mpd?a=1597142191%2CY2JkNmU5Y2FmZGM1NzA5MjhkYTk5NjdmMWRmNWI4M2I2N2Q2MjA5NmIzZWRmODJiMjk0MzY4OTZlYTBiZmZlZg%3D%3D&v=1&f=sd", - "duration": 31, - "hls_url": "https://v.redd.it/tqzx750wzda51/HLSPlaylist.m3u8?a=1597142191%2CZDVhNWNjMGQ0OTBjOTU0Zjk5MDgwZmE2YzA1MGY5YzNlZThmZTAxZTgxODIxMGFjZDdlYzczOWFlYTcyMmMzNg%3D%3D&v=1&f=sd", - "is_gif": False, - "transcoding_status": "completed", - } - }, - "is_reddit_media_domain": True, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "I Made This", - "can_mod_post": False, - "score": 439, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594571350, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "v.redd.it", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://v.redd.it/tqzx750wzda51", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "d7dfae22-4113-11ea-b9fe-0e741fe75651", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": "Neuroscientist", - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh0y", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hpr28u", - "is_robot_indexable": True, - "report_reasons": None, - "author": "Sebaron", - "discussion_type": None, - "num_comments": 33, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": "dark", - "permalink": "/r/Python/comments/hpr28u/i_am_a_medical_student_and_i_recently_programmed/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://v.redd.it/tqzx750wzda51", - "subreddit_subscribers": 616297, - "created_utc": 1594542550, - "num_crossposts": 0, - "media": { - "reddit_video": { - "fallback_url": "https://v.redd.it/tqzx750wzda51/DASH_360.mp4?source=fallback", - "height": 384, - "width": 512, - "scrubber_media_url": "https://v.redd.it/tqzx750wzda51/DASH_96.mp4", - "dash_url": "https://v.redd.it/tqzx750wzda51/DASHPlaylist.mpd?a=1597142191%2CY2JkNmU5Y2FmZGM1NzA5MjhkYTk5NjdmMWRmNWI4M2I2N2Q2MjA5NmIzZWRmODJiMjk0MzY4OTZlYTBiZmZlZg%3D%3D&v=1&f=sd", - "duration": 31, - "hls_url": "https://v.redd.it/tqzx750wzda51/HLSPlaylist.m3u8?a=1597142191%2CZDVhNWNjMGQ0OTBjOTU0Zjk5MDgwZmE2YzA1MGY5YzNlZThmZTAxZTgxODIxMGFjZDdlYzczOWFlYTcyMmMzNg%3D%3D&v=1&f=sd", - "is_gif": False, - "transcoding_status": "completed", - } - }, - "is_video": True, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "Python", - "selftext": "", - "author_fullname": "t2_6zgzj94n", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "I made a filename simplifier which removes unnecessary tags, metadata, dashes, dots, underscores, and non-English characters from filenames (and folders) to give your library a neat look.", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/Python", - "hidden": False, - "pwls": 6, - "link_flair_css_class": "made-this", - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hpps6f", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.95, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 258, - "total_awards_received": 1, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": { - "reddit_video": { - "fallback_url": "https://v.redd.it/jq229anzada51/DASH_1080.mp4?source=fallback", - "height": 1080, - "width": 1920, - "scrubber_media_url": "https://v.redd.it/jq229anzada51/DASH_96.mp4", - "dash_url": "https://v.redd.it/jq229anzada51/DASHPlaylist.mpd?a=1597142191%2CZDU4Y2FmYzI2NjMzZTMxNzJkOThiMzJmYzBlOTMyMmEwNTg3MTFhMmU0OWZjZDljZGQ4MjAwMTgxMGVhYzU1OQ%3D%3D&v=1&f=sd", - "duration": 27, - "hls_url": "https://v.redd.it/jq229anzada51/HLSPlaylist.m3u8?a=1597142191%2CYmY1Y2Q5ZjQ0ZWVmODAxODQ3MGU3YzA1YzIxOTEzODFlNWQyMjE4MzAyYzNiMDM5NTI0N2M5OTRmY2YwN2NlOA%3D%3D&v=1&f=sd", - "is_gif": False, - "transcoding_status": "completed", - } - }, - "is_reddit_media_domain": True, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "I Made This", - "can_mod_post": False, - "score": 258, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594563987, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "v.redd.it", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://v.redd.it/jq229anzada51", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [ - { - "giver_coin_reward": 0, - "subreddit_id": None, - "is_new": False, - "days_of_drip_extension": 0, - "coin_price": 75, - "id": "award_9663243a-e77f-44cf-abc6-850ead2cd18d", - "penny_donate": 0, - "award_sub_type": "PREMIUM", - "coin_reward": 0, - "icon_url": "https://www.redditstatic.com/gold/awards/icon/SnooClappingPremium_512.png", - "days_of_premium": 0, - "resized_icons": [ - { - "url": "https://www.redditstatic.com/gold/awards/icon/SnooClappingPremium_16.png", - "width": 16, - "height": 16, - }, - { - "url": "https://www.redditstatic.com/gold/awards/icon/SnooClappingPremium_32.png", - "width": 32, - "height": 32, - }, - { - "url": "https://www.redditstatic.com/gold/awards/icon/SnooClappingPremium_48.png", - "width": 48, - "height": 48, - }, - { - "url": "https://www.redditstatic.com/gold/awards/icon/SnooClappingPremium_64.png", - "width": 64, - "height": 64, - }, - { - "url": "https://www.redditstatic.com/gold/awards/icon/SnooClappingPremium_128.png", - "width": 128, - "height": 128, - }, - ], - "icon_width": 512, - "static_icon_width": 512, - "start_date": None, - "is_enabled": True, - "description": "For an especially amazing showing.", - "end_date": None, - "subreddit_coin_reward": 0, - "count": 1, - "static_icon_height": 512, - "name": "Bravo Grande!", - "resized_static_icons": [ - { - "url": "https://preview.redd.it/award_images/t5_q0gj4/59e02tmkl4451_BravoGrande-Static.png?width=16&height=16&auto=webp&s=3459bdf1d1777821a831c5bf9834f4365263fcff", - "width": 16, - "height": 16, - }, - { - "url": "https://preview.redd.it/award_images/t5_q0gj4/59e02tmkl4451_BravoGrande-Static.png?width=32&height=32&auto=webp&s=9181d68065ccfccf2b1074e499cd7c1103aa2ce8", - "width": 32, - "height": 32, - }, - { - "url": "https://preview.redd.it/award_images/t5_q0gj4/59e02tmkl4451_BravoGrande-Static.png?width=48&height=48&auto=webp&s=339b368d395219120abc50d54fb3e2cdcad8ca4f", - "width": 48, - "height": 48, - }, - { - "url": "https://preview.redd.it/award_images/t5_q0gj4/59e02tmkl4451_BravoGrande-Static.png?width=64&height=64&auto=webp&s=de4ebbe92f9019de05aaa77f88810d44adbe1e50", - "width": 64, - "height": 64, - }, - { - "url": "https://preview.redd.it/award_images/t5_q0gj4/59e02tmkl4451_BravoGrande-Static.png?width=128&height=128&auto=webp&s=ba6c1add5204ea43e5af010bd9622392a42140e3", - "width": 128, - "height": 128, - }, - ], - "icon_format": "APNG", - "icon_height": 512, - "penny_price": 0, - "award_type": "global", - "static_icon_url": "https://i.redd.it/award_images/t5_q0gj4/59e02tmkl4451_BravoGrande-Static.png", - } - ], - "awarders": [], - "media_only": False, - "link_flair_template_id": "d7dfae22-4113-11ea-b9fe-0e741fe75651", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh0y", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hpps6f", - "is_robot_indexable": True, - "report_reasons": None, - "author": "Hobo-TheGodOfPoverty", - "discussion_type": None, - "num_comments": 25, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/Python/comments/hpps6f/i_made_a_filename_simplifier_which_removes/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://v.redd.it/jq229anzada51", - "subreddit_subscribers": 616297, - "created_utc": 1594535187, - "num_crossposts": 0, - "media": { - "reddit_video": { - "fallback_url": "https://v.redd.it/jq229anzada51/DASH_1080.mp4?source=fallback", - "height": 1080, - "width": 1920, - "scrubber_media_url": "https://v.redd.it/jq229anzada51/DASH_96.mp4", - "dash_url": "https://v.redd.it/jq229anzada51/DASHPlaylist.mpd?a=1597142191%2CZDU4Y2FmYzI2NjMzZTMxNzJkOThiMzJmYzBlOTMyMmEwNTg3MTFhMmU0OWZjZDljZGQ4MjAwMTgxMGVhYzU1OQ%3D%3D&v=1&f=sd", - "duration": 27, - "hls_url": "https://v.redd.it/jq229anzada51/HLSPlaylist.m3u8?a=1597142191%2CYmY1Y2Q5ZjQ0ZWVmODAxODQ3MGU3YzA1YzIxOTEzODFlNWQyMjE4MzAyYzNiMDM5NTI0N2M5OTRmY2YwN2NlOA%3D%3D&v=1&f=sd", - "is_gif": False, - "transcoding_status": "completed", - } - }, - "is_video": True, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "Python", - "selftext": "", - "author_fullname": "t2_1kjpn251", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Concept Art: what might python look like in Japanese, without any English characters?", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/Python", - "hidden": False, - "pwls": 6, - "link_flair_css_class": "discussion", - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hp7uqe", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.94, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 1697, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": True, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "Discussion", - "can_mod_post": False, - "score": 1697, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "crosspost_parent_list": [ - { - "approved_at_utc": None, - "subreddit": "ProgrammingLanguages", - "selftext": "", - "author_fullname": "t2_f4rdtgk", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Concept Art: what might python look like in Japanese, without any English characters?", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/ProgrammingLanguages", - "hidden": False, - "pwls": 6, - "link_flair_css_class": "", - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_g9iu8x", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.96, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 440, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": True, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "Discussion", - "can_mod_post": False, - "score": 440, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1588088407, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "i.redd.it", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://i.redd.it/ulc23n21jiv41.png", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "93811e06-0da7-11e8-a9a2-0e1129ea8e52", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qi8m", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "g9iu8x", - "is_robot_indexable": True, - "report_reasons": None, - "author": "MartialArtTetherball", - "discussion_type": None, - "num_comments": 65, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/ProgrammingLanguages/comments/g9iu8x/concept_art_what_might_python_look_like_in/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://i.redd.it/ulc23n21jiv41.png", - "subreddit_subscribers": 43859, - "created_utc": 1588059607, - "num_crossposts": 2, - "media": None, - "is_video": False, - } - ], - "created": 1594492194, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "i.redd.it", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://i.redd.it/ulc23n21jiv41.png", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "0df42996-1c5e-11ea-b1a0-0e44e1c5b731", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh0y", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hp7uqe", - "is_robot_indexable": True, - "report_reasons": None, - "author": "SubstantialRange", - "discussion_type": None, - "num_comments": 182, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "crosspost_parent": "t3_g9iu8x", - "author_flair_text_color": None, - "permalink": "/r/Python/comments/hp7uqe/concept_art_what_might_python_look_like_in/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://i.redd.it/ulc23n21jiv41.png", - "subreddit_subscribers": 616297, - "created_utc": 1594463394, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - ], - "after": "t3_hozdzo", - "before": None, - }, -} - -empty_mock = { - "kind": "Listing", - "data": { - "modhash": "y4he8gfzh9f892e2bf3094bc06daba2e02288e617fecf555b5", - "dist": 27, - "children": [], - "after": "t3_hozdzo", - "before": None, - }, -} diff --git a/src/newsreader/news/collection/tests/reddit/collector/tests.py b/src/newsreader/news/collection/tests/reddit/collector/tests.py deleted file mode 100644 index c65020d..0000000 --- a/src/newsreader/news/collection/tests/reddit/collector/tests.py +++ /dev/null @@ -1,201 +0,0 @@ -from datetime import datetime, timezone -from unittest.mock import patch -from uuid import uuid4 - -from django.test import TestCase - -from newsreader.news.collection.choices import RuleTypeChoices -from newsreader.news.collection.exceptions import ( - StreamDeniedException, - StreamForbiddenException, - StreamNotFoundException, - StreamTimeOutException, -) -from newsreader.news.collection.reddit import RedditCollector -from newsreader.news.collection.tests.factories import SubredditFactory -from newsreader.news.collection.tests.reddit.collector.mocks import ( - empty_mock, - simple_mock_1, - simple_mock_2, -) -from newsreader.news.core.models import Post - - -class RedditCollectorTestCase(TestCase): - def setUp(self): - self.maxDiff = None - - self.patched_get = patch("newsreader.news.collection.reddit.fetch") - self.mocked_fetch = self.patched_get.start() - - self.patched_parse = patch( - "newsreader.news.collection.reddit.RedditStream.parse" - ) - self.mocked_parse = self.patched_parse.start() - - def tearDown(self): - patch.stopall() - - def test_simple_batch(self): - self.mocked_parse.side_effect = (simple_mock_1, simple_mock_2) - - rules = ( - (subreddit,) - for subreddit in SubredditFactory.create_batch( - user__reddit_access_token=str(uuid4()), - user__reddit_refresh_token=str(uuid4()), - enabled=True, - size=2, - ) - ) - - collector = RedditCollector() - collector.collect(rules=rules) - - self.assertCountEqual( - Post.objects.values_list("remote_identifier", flat=True), - ( - "hm6byg", - "hpkhgj", - "hph00n", - "hp9mlw", - "hpjn8x", - "gdfaip", - "hmd2ez", - "hpr28u", - "hpps6f", - "hp7uqe", - ), - ) - - for subreddit in rules: - with self.subTest(subreddit=subreddit): - self.assertEqual(subreddit.succeeded, True) - self.assertEqual(subreddit.last_run, datetime.now(tz=timezone.utc)) - self.assertEqual(subreddit.error, None) - - post = Post.objects.get( - remote_identifier="hph00n", rule__type=RuleTypeChoices.subreddit - ) - - self.assertEqual( - post.publication_date, - datetime(2020, 7, 11, 22, 23, 24, tzinfo=timezone.utc), - ) - - self.assertEqual(post.author, "HannahB888") - self.assertEqual(post.title, "Drake Interplanetary Smartkey thing that I made!") - self.assertEqual( - post.url, - "https://www.reddit.com/r/starcitizen/comments/hph00n/drake_interplanetary_smartkey_thing_that_i_made/", - ) - - post = Post.objects.get( - remote_identifier="hpr28u", rule__type=RuleTypeChoices.subreddit - ) - - self.assertEqual( - post.publication_date, - datetime(2020, 7, 12, 10, 29, 10, tzinfo=timezone.utc), - ) - - self.assertEqual(post.author, "Sebaron") - self.assertEqual( - post.title, - "I am a medical student, and I recently programmed an open-source eye-tracker for brain research", - ) - self.assertEqual( - post.url, - "https://www.reddit.com/r/Python/comments/hpr28u/i_am_a_medical_student_and_i_recently_programmed/", - ) - - def test_empty_batch(self): - self.mocked_parse.side_effect = (empty_mock, empty_mock) - - rules = ( - (subreddit,) - for subreddit in SubredditFactory.create_batch( - user__reddit_access_token=str(uuid4()), - user__reddit_refresh_token=str(uuid4()), - enabled=True, - size=2, - ) - ) - - collector = RedditCollector() - collector.collect(rules=rules) - - self.assertEqual(Post.objects.count(), 0) - - for subreddit in rules: - with self.subTest(subreddit=subreddit): - self.assertEqual(subreddit.succeeded, True) - self.assertEqual(subreddit.last_run, datetime.now(tz=timezone.utc)) - self.assertEqual(subreddit.error, None) - - def test_not_found(self): - self.mocked_fetch.side_effect = StreamNotFoundException - - rule = SubredditFactory( - user__reddit_access_token=str(uuid4()), - user__reddit_refresh_token=str(uuid4()), - enabled=True, - ) - - collector = RedditCollector() - collector.collect(rules=((rule,),)) - - self.assertEqual(Post.objects.count(), 0) - self.assertEqual(rule.succeeded, False) - self.assertEqual(rule.error, "Stream not found") - - @patch("newsreader.news.collection.reddit.RedditTokenTask") - def test_denied(self, mocked_task): - self.mocked_fetch.side_effect = StreamDeniedException - - rule = SubredditFactory( - user__reddit_access_token=str(uuid4()), - user__reddit_refresh_token=str(uuid4()), - enabled=True, - ) - - collector = RedditCollector() - collector.collect(rules=((rule,),)) - - self.assertEqual(Post.objects.count(), 0) - self.assertEqual(rule.succeeded, False) - self.assertEqual(rule.error, "Stream does not have sufficient permissions") - - mocked_task.delay.assert_called_once_with(rule.user.pk) - - def test_forbidden(self): - self.mocked_fetch.side_effect = StreamForbiddenException - - rule = SubredditFactory( - user__reddit_access_token=str(uuid4()), - user__reddit_refresh_token=str(uuid4()), - enabled=True, - ) - - collector = RedditCollector() - collector.collect(rules=((rule,),)) - - self.assertEqual(Post.objects.count(), 0) - self.assertEqual(rule.succeeded, False) - self.assertEqual(rule.error, "Stream forbidden") - - def test_timed_out(self): - self.mocked_fetch.side_effect = StreamTimeOutException - - rule = SubredditFactory( - user__reddit_access_token=str(uuid4()), - user__reddit_refresh_token=str(uuid4()), - enabled=True, - ) - - collector = RedditCollector() - collector.collect(rules=((rule,),)) - - self.assertEqual(Post.objects.count(), 0) - self.assertEqual(rule.succeeded, False) - self.assertEqual(rule.error, "Stream timed out") diff --git a/src/newsreader/news/collection/tests/reddit/stream/__init__.py b/src/newsreader/news/collection/tests/reddit/stream/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/newsreader/news/collection/tests/reddit/stream/mocks.py b/src/newsreader/news/collection/tests/reddit/stream/mocks.py deleted file mode 100644 index 148b31a..0000000 --- a/src/newsreader/news/collection/tests/reddit/stream/mocks.py +++ /dev/null @@ -1,3289 +0,0 @@ -simple_mock = { - "kind": "Listing", - "data": { - "modhash": "sgq4fdizx94db5c05b57f9957a4b8b2d5e24b712f5a507cffd", - "dist": 27, - "children": [ - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "Welcome to r/linux rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.\n\nLet us know what's annoying you, whats making you happy, or something that you want to get out to r/linux but didn't make the cut into a full post of it's own.\n\nFor those looking for certifications please use this megathread to ask about how to get certified whether it's for the business world or for your own satisfaction. Be sure to check out r/linuxadmin for more discussion in the SysAdmin world!\n\n_Please keep questions in r/linuxquestions, r/linux4noobs, or the Wednesday automod thread._", - "author_fullname": "t2_6l4z3", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hm0qct", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.65, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 6, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 6, - "approved_by": None, - "author_premium": True, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594037482.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.linux", - "allow_live_comments": False, - "selftext_html": '<!-- SC_OFF --><div class="md"><p>Welcome to <a href="/r/linux">r/linux</a> rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.</p>\n\n<p>Let us know what&#39;s annoying you, whats making you happy, or something that you want to get out to <a href="/r/linux">r/linux</a> but didn&#39;t make the cut into a full post of it&#39;s own.</p>\n\n<p>For those looking for certifications please use this megathread to ask about how to get certified whether it&#39;s for the business world or for your own satisfaction. Be sure to check out <a href="/r/linuxadmin">r/linuxadmin</a> for more discussion in the SysAdmin world!</p>\n\n<p><em>Please keep questions in <a href="/r/linuxquestions">r/linuxquestions</a>, <a href="/r/linux4noobs">r/linux4noobs</a>, or the Wednesday automod thread.</em></p>\n</div><!-- SC_ON -->', - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": True, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": "moderator", - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hm0qct", - "is_robot_indexable": True, - "report_reasons": None, - "author": "AutoModerator", - "discussion_type": None, - "num_comments": 8, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", - "parent_whitelist_status": "all_ads", - "stickied": True, - "url": "https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", - "subreddit_subscribers": 543995, - "created_utc": 1594008682.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "Welcome to r/linux! If you're new to Linux or trying to get started this thread is for you. Get help here or as always, check out r/linuxquestions or r/linux4noobs\n\nThis megathread is for all your question needs. As we don't allow questions on r/linux outside of this megathread, please consider using r/linuxquestions or r/linux4noobs for the best solution to your problem.\n\nAsk your hardware requests here too or try r/linuxhardware!", - "author_fullname": "t2_6l4z3", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Weekly Questions and Hardware Thread - July 08, 2020", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hna75r", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.5, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 0, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 0, - "approved_by": None, - "author_premium": True, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594210138.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.linux", - "allow_live_comments": False, - "selftext_html": '<!-- SC_OFF --><div class="md"><p>Welcome to <a href="/r/linux">r/linux</a>! If you&#39;re new to Linux or trying to get started this thread is for you. Get help here or as always, check out <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a></p>\n\n<p>This megathread is for all your question needs. As we don&#39;t allow questions on <a href="/r/linux">r/linux</a> outside of this megathread, please consider using <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a> for the best solution to your problem.</p>\n\n<p>Ask your hardware requests here too or try <a href="/r/linuxhardware">r/linuxhardware</a>!</p>\n</div><!-- SC_ON -->', - "likes": None, - "suggested_sort": "new", - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": True, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": "moderator", - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hna75r", - "is_robot_indexable": True, - "report_reasons": None, - "author": "AutoModerator", - "discussion_type": None, - "num_comments": 2, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", - "parent_whitelist_status": "all_ads", - "stickied": True, - "url": "https://www.reddit.com/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", - "subreddit_subscribers": 543995, - "created_utc": 1594181338.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "", - "author_fullname": "t2_gr7k5", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Here's a feature Linux could borrow from BSD: in-kernel debugger with built-in hangman game", - "link_flair_richtext": [{"e": "text", "t": "Fluff"}], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": "", - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hngs71", - "quarantine": False, - "link_flair_text_color": "light", - "upvote_ratio": 0.9, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 135, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": True, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "Fluff", - "can_mod_post": False, - "score": 135, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594242629.0, - "link_flair_type": "richtext", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "i.redd.it", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://i.redd.it/wmc8tp2ium951.jpg", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "af8918be-6777-11e7-8273-0e925d908786", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "#9a2bff", - "id": "hngs71", - "is_robot_indexable": True, - "report_reasons": None, - "author": "the_humeister", - "discussion_type": None, - "num_comments": 20, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hngs71/heres_a_feature_linux_could_borrow_from_bsd/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://i.redd.it/wmc8tp2ium951.jpg", - "subreddit_subscribers": 543995, - "created_utc": 1594213829.0, - "num_crossposts": 1, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "", - "author_fullname": "t2_k9f35", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "KeePassXC 2.6.0 released", - "link_flair_richtext": [{"e": "text", "t": "Software Release"}], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": "", - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hngsj8", - "quarantine": False, - "link_flair_text_color": "light", - "upvote_ratio": 0.97, - "author_flair_background_color": "transparent", - "subreddit_type": "public", - "ups": 126, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": "fa602e36-cdf6-11e8-93c9-0e41ac35f4cc", - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "Software Release", - "can_mod_post": False, - "score": 126, - "approved_by": None, - "author_premium": True, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [ - { - "a": ":ubuntu:", - "e": "emoji", - "u": "https://emoji.redditmedia.com/uwmddx7qqpr11_t5_2qh1a/ubuntu", - } - ], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594242666.0, - "link_flair_type": "richtext", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "richtext", - "domain": "keepassxc.org", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://keepassxc.org/blog/2020-07-07-2.6.0-released/", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "904ea3e4-6748-11e7-b925-0ef3dfbb807a", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": ":ubuntu:", - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "#349e48", - "id": "hngsj8", - "is_robot_indexable": True, - "report_reasons": None, - "author": "nixcraft", - "discussion_type": None, - "num_comments": 42, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": "dark", - "permalink": "/r/linux/comments/hngsj8/keepassxc_260_released/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://keepassxc.org/blog/2020-07-07-2.6.0-released/", - "subreddit_subscribers": 543995, - "created_utc": 1594213866.0, - "num_crossposts": 1, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "", - "author_fullname": "t2_hlv0o", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', - "link_flair_richtext": [{"e": "text", "t": "Popular Application"}], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hnd7cy", - "quarantine": False, - "link_flair_text_color": "light", - "upvote_ratio": 0.95, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 223, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "Popular Application", - "can_mod_post": False, - "score": 223, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "crosspost_parent_list": [ - { - "approved_at_utc": None, - "subreddit": "libreoffice", - "selftext": "", - "author_fullname": "t2_hlv0o", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/libreoffice", - "hidden": False, - "pwls": 6, - "link_flair_css_class": "", - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hnd6yo", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.94, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 28, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "News", - "can_mod_post": False, - "score": 28, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594224961.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "blog.documentfoundation.org", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "dc82ac98-bafb-11e4-9f88-22000b310327", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2s4nt", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hnd6yo", - "is_robot_indexable": True, - "report_reasons": None, - "author": "TheQuantumZero", - "discussion_type": None, - "num_comments": 38, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/libreoffice/comments/hnd6yo/board_statement_on_the_libreoffice_70_rc_personal/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", - "subreddit_subscribers": 4669, - "created_utc": 1594196161.0, - "num_crossposts": 2, - "media": None, - "is_video": False, - } - ], - "created": 1594225018.0, - "link_flair_type": "richtext", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "blog.documentfoundation.org", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "7127ec98-5859-11e8-9488-0e8717893ec8", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "#0aa18f", - "id": "hnd7cy", - "is_robot_indexable": True, - "report_reasons": None, - "author": "TheQuantumZero", - "discussion_type": None, - "num_comments": 109, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "crosspost_parent": "t3_hnd6yo", - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hnd7cy/board_statement_on_the_libreoffice_70_rc_personal/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", - "subreddit_subscribers": 543995, - "created_utc": 1594196218.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "", - "author_fullname": "t2_6cxnzaq2", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Gentoo Now on Android Platform !!!", - "link_flair_richtext": [{"e": "text", "t": "Mobile Linux"}], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": "", - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hnemei", - "quarantine": False, - "link_flair_text_color": "light", - "upvote_ratio": 0.87, - "author_flair_background_color": "transparent", - "subreddit_type": "public", - "ups": 78, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": "a54a7460-cdf6-11e8-b31c-0e89679a2148", - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "Mobile Linux", - "can_mod_post": False, - "score": 78, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [ - { - "a": ":arch:", - "e": "emoji", - "u": "https://emoji.redditmedia.com/tip79drnqpr11_t5_2qh1a/arch", - } - ], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594232773.0, - "link_flair_type": "richtext", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "richtext", - "domain": "gentoo.org", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://www.gentoo.org/news/2020/07/07/gentoo-android.html", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "84162644-5859-11e8-b9ed-0efda312d094", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": ":arch:", - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "#d78216", - "id": "hnemei", - "is_robot_indexable": True, - "report_reasons": None, - "author": "draplon", - "discussion_type": None, - "num_comments": 21, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": "dark", - "permalink": "/r/linux/comments/hnemei/gentoo_now_on_android_platform/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://www.gentoo.org/news/2020/07/07/gentoo-android.html", - "subreddit_subscribers": 543995, - "created_utc": 1594203973.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "", - "author_fullname": "t2_f9vxe", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Google is teaming up with Ubuntu to bring Flutter apps to Linux", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hniojf", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.77, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 31, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 31, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594249580.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "androidpolice.com", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://www.androidpolice.com/2020/07/08/google-is-teaming-up-with-ubuntu-to-bring-flutter-apps-to-linux/", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hniojf", - "is_robot_indexable": True, - "report_reasons": None, - "author": "bilal4hmed", - "discussion_type": None, - "num_comments": 24, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hniojf/google_is_teaming_up_with_ubuntu_to_bring_flutter/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://www.androidpolice.com/2020/07/08/google-is-teaming-up-with-ubuntu-to-bring-flutter-apps-to-linux/", - "subreddit_subscribers": 543995, - "created_utc": 1594220780.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "", - "author_fullname": "t2_k9f35", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Ariane RISC-V CPU \u2013 An open source CPU capable of booting Linux", - "link_flair_richtext": [{"e": "text", "t": "Hardware"}], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": "", - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hngr1j", - "quarantine": False, - "link_flair_text_color": "light", - "upvote_ratio": 0.89, - "author_flair_background_color": "transparent", - "subreddit_type": "public", - "ups": 49, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": "fa602e36-cdf6-11e8-93c9-0e41ac35f4cc", - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "Hardware", - "can_mod_post": False, - "score": 49, - "approved_by": None, - "author_premium": True, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [ - { - "a": ":ubuntu:", - "e": "emoji", - "u": "https://emoji.redditmedia.com/uwmddx7qqpr11_t5_2qh1a/ubuntu", - } - ], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594242511.0, - "link_flair_type": "richtext", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "richtext", - "domain": "github.com", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://github.com/openhwgroup/cva6", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "3d48793a-c823-11e8-9a58-0ee3c97eb952", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": ":ubuntu:", - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "#cc5289", - "id": "hngr1j", - "is_robot_indexable": True, - "report_reasons": None, - "author": "nixcraft", - "discussion_type": None, - "num_comments": 15, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": "dark", - "permalink": "/r/linux/comments/hngr1j/ariane_riscv_cpu_an_open_source_cpu_capable_of/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://github.com/openhwgroup/cva6", - "subreddit_subscribers": 543995, - "created_utc": 1594213711.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "", - "author_fullname": "t2_6kt9ukjs", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Canonical enables Linux desktop app support with Flutter", - "link_flair_richtext": [{"e": "text", "t": "Software Release"}], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": "", - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hnj1ap", - "quarantine": False, - "link_flair_text_color": "light", - "upvote_ratio": 0.79, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 24, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "Software Release", - "can_mod_post": False, - "score": 24, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594250752.0, - "link_flair_type": "richtext", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "ubuntu.com", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://ubuntu.com/blog/canonical-enables-linux-desktop-app-support-with-flutter", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "904ea3e4-6748-11e7-b925-0ef3dfbb807a", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "#349e48", - "id": "hnj1ap", - "is_robot_indexable": True, - "report_reasons": None, - "author": "hmblhstl", - "discussion_type": None, - "num_comments": 28, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hnj1ap/canonical_enables_linux_desktop_app_support_with/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://ubuntu.com/blog/canonical-enables-linux-desktop-app-support-with-flutter", - "subreddit_subscribers": 543995, - "created_utc": 1594221952.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "", - "author_fullname": "t2_3vf8x", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Sandboxing in Linux with zero lines of code", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hnfzbm", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.83, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 30, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 30, - "approved_by": None, - "author_premium": True, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594239285.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "blog.cloudflare.com", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://blog.cloudflare.com/sandboxing-in-linux-with-zero-lines-of-code/", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hnfzbm", - "is_robot_indexable": True, - "report_reasons": None, - "author": "pimterry", - "discussion_type": None, - "num_comments": 0, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hnfzbm/sandboxing_in_linux_with_zero_lines_of_code/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://blog.cloudflare.com/sandboxing-in-linux-with-zero-lines-of-code/", - "subreddit_subscribers": 543995, - "created_utc": 1594210485.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "", - "author_fullname": "t2_318in", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "SUSE Enters Into Definitive Agreement to Acquire Rancher Labs", - "link_flair_richtext": [ - {"e": "text", "t": "Open Source Organization"} - ], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": "", - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hnh5ux", - "quarantine": False, - "link_flair_text_color": "light", - "upvote_ratio": 0.84, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 26, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "Open Source Organization", - "can_mod_post": False, - "score": 26, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594244123.0, - "link_flair_type": "richtext", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "rancher.com", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://rancher.com/blog/2020/suse-to-acquire-rancher/", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "8a1dd4b0-5859-11e8-a2c7-0e5ebdbe24d6", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "#800000", - "id": "hnh5ux", - "is_robot_indexable": True, - "report_reasons": None, - "author": "hjames9", - "discussion_type": None, - "num_comments": 5, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hnh5ux/suse_enters_into_definitive_agreement_to_acquire/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://rancher.com/blog/2020/suse-to-acquire-rancher/", - "subreddit_subscribers": 543995, - "created_utc": 1594215323.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "", - "author_fullname": "t2_j1a5", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Linux Mint drops Ubuntu Snap packages [LWN.net]", - "link_flair_richtext": [{"e": "text", "t": "Distro News"}], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": "", - "downs": 0, - "top_awarded_type": None, - "hide_score": True, - "name": "t3_hnlt4l", - "quarantine": False, - "link_flair_text_color": "light", - "upvote_ratio": 0.8, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 9, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "Distro News", - "can_mod_post": False, - "score": 9, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594259641.0, - "link_flair_type": "richtext", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "lwn.net", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://lwn.net/SubscriberLink/825005/6440c82feb745bbe/", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "6888e772-5859-11e8-82ff-0e816ab71260", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "#0dd3bb", - "id": "hnlt4l", - "is_robot_indexable": True, - "report_reasons": None, - "author": "tapo", - "discussion_type": None, - "num_comments": 3, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hnlt4l/linux_mint_drops_ubuntu_snap_packages_lwnnet/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://lwn.net/SubscriberLink/825005/6440c82feb745bbe/", - "subreddit_subscribers": 543995, - "created_utc": 1594230841.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "", - "author_fullname": "t2_4i3yk", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Announcing Flutter Linux Alpha with Canonical", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hniq04", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.6, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 6, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 6, - "approved_by": None, - "author_premium": True, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594249712.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "medium.com", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://medium.com/flutter/announcing-flutter-linux-alpha-with-canonical-19eb824590a9", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hniq04", - "is_robot_indexable": True, - "report_reasons": None, - "author": "popeydc", - "discussion_type": None, - "num_comments": 3, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hniq04/announcing_flutter_linux_alpha_with_canonical/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://medium.com/flutter/announcing-flutter-linux-alpha-with-canonical-19eb824590a9", - "subreddit_subscribers": 543995, - "created_utc": 1594220912.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "", - "author_fullname": "t2_611c0ard", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "New anti-encryption bill worse than EARN IT, would force a backdoor into any US device/software. Act now to stop both.", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hmp66i", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.98, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 3340, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 3340, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594131589.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "tutanota.com", - "allow_live_comments": True, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://tutanota.com/blog/posts/lawful-access-encrypted-data-act-backdoor", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hmp66i", - "is_robot_indexable": True, - "report_reasons": None, - "author": "fossfans", - "discussion_type": None, - "num_comments": 380, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hmp66i/new_antiencryption_bill_worse_than_earn_it_would/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://tutanota.com/blog/posts/lawful-access-encrypted-data-act-backdoor", - "subreddit_subscribers": 543995, - "created_utc": 1594102789.0, - "num_crossposts": 2, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "We have had Freesync \"support\" for quite some time now, but it is extremely restrictive and very picky to get it working. Just the requirements to have Freesync working is no-go for many:\n\n\\-> Single monitor only;\n\n\\-> No video playback or turning it on while on desktop;\n\n\\-> Should only be turned on only while the game/software in question is in fullscreen;\n\n\\-> X11, no Wayland;\n\n\\-> Only tested/working distro is Ubuntu 16.04.3;\n\n\\-> Need of setting it up through some quite cryptic commands;\n\n\\-> Doesn't work after hotplug or system restart;\n\n\\-> No Freesync over HDMI (which isn't a massive problem, but a nice option to have);\n\n\\-> Apparently only OpenGL, no Vulkan (Steam Play/Proton, which is the main purpose for Freesync at the moment, doesn't work);\n\n&#x200B;\n\nI am not really complaining, because I do know that Freesync is hard to get working on Linux, but we have had so many advancements on the gaming side of Linux, and we are still stuck with all of these restrictions to use Freesync, which is quite a useful functionality for almost every gamer. If Mozilla got video decoding working well on Wayland, I hope (Idk anything about this, just hoping) that it could also be easy to implement Freesync on Wayland too.\n\nWe just haven't had that many improvements on this side of the Linux gaming world, and I'd like to know if it is lack of support/interest by AMD, or if it actually is extremely hard to implement it on Linux. Freesync would also be very useful for those who are running monitors over 60Hz, so that those 60FPS videos don't look as weird as they do while playing back on higher refresh rate monitors. It is just a nice thing for everybody, really!", - "author_fullname": "t2_1afv9v8g", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Any evolution on the Freesync situation on Linux?", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hn7agp", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.85, - "author_flair_background_color": "transparent", - "subreddit_type": "public", - "ups": 83, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": "fa602e36-cdf6-11e8-93c9-0e41ac35f4cc", - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 83, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [ - { - "a": ":ubuntu:", - "e": "emoji", - "u": "https://emoji.redditmedia.com/uwmddx7qqpr11_t5_2qh1a/ubuntu", - } - ], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594199056.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "richtext", - "domain": "self.linux", - "allow_live_comments": False, - "selftext_html": '<!-- SC_OFF --><div class="md"><p>We have had Freesync &quot;support&quot; for quite some time now, but it is extremely restrictive and very picky to get it working. Just the requirements to have Freesync working is no-go for many:</p>\n\n<p>-&gt; Single monitor only;</p>\n\n<p>-&gt; No video playback or turning it on while on desktop;</p>\n\n<p>-&gt; Should only be turned on only while the game/software in question is in fullscreen;</p>\n\n<p>-&gt; X11, no Wayland;</p>\n\n<p>-&gt; Only tested/working distro is Ubuntu 16.04.3;</p>\n\n<p>-&gt; Need of setting it up through some quite cryptic commands;</p>\n\n<p>-&gt; Doesn&#39;t work after hotplug or system restart;</p>\n\n<p>-&gt; No Freesync over HDMI (which isn&#39;t a massive problem, but a nice option to have);</p>\n\n<p>-&gt; Apparently only OpenGL, no Vulkan (Steam Play/Proton, which is the main purpose for Freesync at the moment, doesn&#39;t work);</p>\n\n<p>&#x200B;</p>\n\n<p>I am not really complaining, because I do know that Freesync is hard to get working on Linux, but we have had so many advancements on the gaming side of Linux, and we are still stuck with all of these restrictions to use Freesync, which is quite a useful functionality for almost every gamer. If Mozilla got video decoding working well on Wayland, I hope (Idk anything about this, just hoping) that it could also be easy to implement Freesync on Wayland too.</p>\n\n<p>We just haven&#39;t had that many improvements on this side of the Linux gaming world, and I&#39;d like to know if it is lack of support/interest by AMD, or if it actually is extremely hard to implement it on Linux. Freesync would also be very useful for those who are running monitors over 60Hz, so that those 60FPS videos don&#39;t look as weird as they do while playing back on higher refresh rate monitors. It is just a nice thing for everybody, really!</p>\n</div><!-- SC_ON -->', - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": ":ubuntu:", - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hn7agp", - "is_robot_indexable": True, - "report_reasons": None, - "author": "mreich98", - "discussion_type": None, - "num_comments": 36, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": "dark", - "permalink": "/r/linux/comments/hn7agp/any_evolution_on_the_freesync_situation_on_linux/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://www.reddit.com/r/linux/comments/hn7agp/any_evolution_on_the_freesync_situation_on_linux/", - "subreddit_subscribers": 543995, - "created_utc": 1594170256.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "", - "author_fullname": "t2_7ccf", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Running Rosetta@home on a Raspberry Pi with Fedora IoT", - "link_flair_richtext": [{"e": "text", "t": "Popular Application"}], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": "", - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hnfw0h", - "quarantine": False, - "link_flair_text_color": "light", - "upvote_ratio": 0.73, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 8, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "Popular Application", - "can_mod_post": False, - "score": 8, - "approved_by": None, - "author_premium": True, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594238884.0, - "link_flair_type": "richtext", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "fedoramagazine.org", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://fedoramagazine.org/running-rosettahome-on-a-raspberry-pi-with-fedora-iot/", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "7127ec98-5859-11e8-9488-0e8717893ec8", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "#0aa18f", - "id": "hnfw0h", - "is_robot_indexable": True, - "report_reasons": None, - "author": "speckz", - "discussion_type": None, - "num_comments": 1, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hnfw0h/running_rosettahome_on_a_raspberry_pi_with_fedora/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://fedoramagazine.org/running-rosettahome-on-a-raspberry-pi-with-fedora-iot/", - "subreddit_subscribers": 543995, - "created_utc": 1594210084.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "", - "author_fullname": "t2_sx11s", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Getting Things GNOME 0.4 released! First release in almost 7 years (Flatpak available).", - "link_flair_richtext": [{"e": "text", "t": "Software Release"}], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hn5wh6", - "quarantine": False, - "link_flair_text_color": "light", - "upvote_ratio": 0.79, - "author_flair_background_color": "transparent", - "subreddit_type": "public", - "ups": 58, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": "2194c338-ce1d-11e8-8ed7-0e20bb1bbc52", - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "Software Release", - "can_mod_post": False, - "score": 58, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [ - { - "a": ":nix:", - "e": "emoji", - "u": "https://emoji.redditmedia.com/ww1ubcjpqpr11_t5_2qh1a/nix", - } - ], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594193982.0, - "link_flair_type": "richtext", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "richtext", - "domain": "flathub.org", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://flathub.org/apps/details/org.gnome.GTG", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "904ea3e4-6748-11e7-b925-0ef3dfbb807a", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": ":nix:", - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "#349e48", - "id": "hn5wh6", - "is_robot_indexable": True, - "report_reasons": None, - "author": "Kanarme", - "discussion_type": None, - "num_comments": 22, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": "dark", - "permalink": "/r/linux/comments/hn5wh6/getting_things_gnome_04_released_first_release_in/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://flathub.org/apps/details/org.gnome.GTG", - "subreddit_subscribers": 543995, - "created_utc": 1594165182.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "", - "author_fullname": "t2_636xx258", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "mpv is not anymore supporting gnome. and the owner reverted the commit again shortly after and then again made a new one, to add the changes", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": True, - "name": "t3_hnnt0v", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 1.0, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 1, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 1, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "crosspost_parent_list": [ - { - "approved_at_utc": None, - "subreddit": "gnome", - "selftext": "", - "author_fullname": "t2_33wgs4m3", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "mpv is not anymore supporting gnome. and the owner reverted the commit again shortly after and then again made a new one, to add the changes", - "link_flair_richtext": [{"e": "text", "t": "News"}], - "subreddit_name_prefixed": "r/gnome", - "hidden": False, - "pwls": 6, - "link_flair_css_class": "", - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hn1s3r", - "quarantine": False, - "link_flair_text_color": "light", - "upvote_ratio": 0.81, - "author_flair_background_color": "transparent", - "subreddit_type": "public", - "ups": 23, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": "1515012e-bed8-11ea-92a7-0eb4e155a177", - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "News", - "can_mod_post": False, - "score": 23, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": "gnome-user", - "author_flair_richtext": [{"e": "text", "t": "GNOMie"}], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594180508.0, - "link_flair_type": "richtext", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "richtext", - "domain": "github.com", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": "confidence", - "banned_at_utc": None, - "url_overridden_by_dest": "https://github.com/mpv-player/mpv/commit/cdaa496314f90412963f2b3211e18df72910066d#commitcomment-40434556", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "7dbe0c80-f9df-11e8-b35e-0e2ae22a2534", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": "GNOMie", - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qjhn", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "#692c52", - "id": "hn1s3r", - "is_robot_indexable": True, - "report_reasons": None, - "author": "idiot10000000", - "discussion_type": None, - "num_comments": 53, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": "dark", - "permalink": "/r/gnome/comments/hn1s3r/mpv_is_not_anymore_supporting_gnome_and_the_owner/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://github.com/mpv-player/mpv/commit/cdaa496314f90412963f2b3211e18df72910066d#commitcomment-40434556", - "subreddit_subscribers": 41350, - "created_utc": 1594151708.0, - "num_crossposts": 1, - "media": None, - "is_video": False, - } - ], - "created": 1594265700.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "github.com", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://github.com/mpv-player/mpv/commit/cdaa496314f90412963f2b3211e18df72910066d#commitcomment-40434556", - "view_count": None, - "archived": False, - "no_follow": True, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hnnt0v", - "is_robot_indexable": True, - "report_reasons": None, - "author": "RetartedTortoise", - "discussion_type": None, - "num_comments": 0, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "crosspost_parent": "t3_hn1s3r", - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hnnt0v/mpv_is_not_anymore_supporting_gnome_and_the_owner/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://github.com/mpv-player/mpv/commit/cdaa496314f90412963f2b3211e18df72910066d#commitcomment-40434556", - "subreddit_subscribers": 543995, - "created_utc": 1594236900.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "", - "author_fullname": "t2_21omsw7y", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Google and Canonical bring Linux apps support to Flutter - 9to5Google", - "link_flair_richtext": [{"e": "text", "t": "Development"}], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": "", - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hnj42j", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.59, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 3, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "Development", - "can_mod_post": False, - "score": 3, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594251002.0, - "link_flair_type": "richtext", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "9to5google.com", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://9to5google.com/2020/07/08/google-canonical-partnership-linux-flutter-apps/", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "3cb511e2-7914-11ea-bb33-0ee30ee9d22b", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "#f0db8a", - "id": "hnj42j", - "is_robot_indexable": True, - "report_reasons": None, - "author": "satvikpendem", - "discussion_type": None, - "num_comments": 1, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hnj42j/google_and_canonical_bring_linux_apps_support_to/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://9to5google.com/2020/07/08/google-canonical-partnership-linux-flutter-apps/", - "subreddit_subscribers": 543995, - "created_utc": 1594222202.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": " As far as I understand it, the current options on the Intel Iris/NVIDIA side are:\n\n* Intel or NVIDIA cards only\n\n* Optimus for switching between Intel and Intel+NVIDIA (requires reboot)\n\n* Bumblebee for on-the-fly switching with a performance hit\n\n* nvidia-xrun, which does everything bumblebee can do but requires a second X server\n\n* Prime Rener Offload, a proprietary NVIDIA thing, for switching between Intel and Intel+NVIDIA, which I don't completely understand\n\nDo I have this right? And how do things look on the Amd Vega/Radeon configuration?", - "author_fullname": "t2_tcnt4", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "[Discussion] What's the current status on laptop switchable graphics?", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": True, - "name": "t3_hnmiik", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.67, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 1, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 1, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594261813.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.linux", - "allow_live_comments": False, - "selftext_html": '<!-- SC_OFF --><div class="md"><p>As far as I understand it, the current options on the Intel Iris/NVIDIA side are:</p>\n\n<ul>\n<li><p>Intel or NVIDIA cards only</p></li>\n<li><p>Optimus for switching between Intel and Intel+NVIDIA (requires reboot)</p></li>\n<li><p>Bumblebee for on-the-fly switching with a performance hit</p></li>\n<li><p>nvidia-xrun, which does everything bumblebee can do but requires a second X server</p></li>\n<li><p>Prime Rener Offload, a proprietary NVIDIA thing, for switching between Intel and Intel+NVIDIA, which I don&#39;t completely understand</p></li>\n</ul>\n\n<p>Do I have this right? And how do things look on the Amd Vega/Radeon configuration?</p>\n</div><!-- SC_ON -->', - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": True, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hnmiik", - "is_robot_indexable": True, - "report_reasons": None, - "author": "KoolDude214", - "discussion_type": None, - "num_comments": 4, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hnmiik/discussion_whats_the_current_status_on_laptop/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://www.reddit.com/r/linux/comments/hnmiik/discussion_whats_the_current_status_on_laptop/", - "subreddit_subscribers": 543995, - "created_utc": 1594233013.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "Hello all!\n\nI've created this simple web app as a part of learning web development, to help people select a linux distro for themselves.\n\nIt's a really simple web app, as I've created it as part of learning web development.\n\nIt retrieves data from another API that I've defined and this very API's database is used to store all the releated information that only right now I can store.\n\nAnd this web app is used to get information from that API and display it in an organized way.\n\nHave a look and please let me know about your thoughts and suggestions:\n\nLink: [https://linux-distros-encyclopedia.herokuapp.com/](https://linux-distros-encyclopedia.herokuapp.com/)", - "author_fullname": "t2_4c9tcvx3", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Linux Distributions Encyclopedia Web App", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hnlh54", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.5, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 0, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 0, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594258586.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.linux", - "allow_live_comments": False, - "selftext_html": '<!-- SC_OFF --><div class="md"><p>Hello all!</p>\n\n<p>I&#39;ve created this simple web app as a part of learning web development, to help people select a linux distro for themselves.</p>\n\n<p>It&#39;s a really simple web app, as I&#39;ve created it as part of learning web development.</p>\n\n<p>It retrieves data from another API that I&#39;ve defined and this very API&#39;s database is used to store all the releated information that only right now I can store.</p>\n\n<p>And this web app is used to get information from that API and display it in an organized way.</p>\n\n<p>Have a look and please let me know about your thoughts and suggestions:</p>\n\n<p>Link: <a href="https://linux-distros-encyclopedia.herokuapp.com/">https://linux-distros-encyclopedia.herokuapp.com/</a></p>\n</div><!-- SC_ON -->', - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": True, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hnlh54", - "is_robot_indexable": True, - "report_reasons": None, - "author": "MisterKhJe", - "discussion_type": None, - "num_comments": 2, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hnlh54/linux_distributions_encyclopedia_web_app/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://www.reddit.com/r/linux/comments/hnlh54/linux_distributions_encyclopedia_web_app/", - "subreddit_subscribers": 543995, - "created_utc": 1594229786.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "I would like to turn my old Asus tablet into an ultimate linux-based Ebook reader. It's currently running kali linux due to my netsec background and I can't say that it runs flawlessly. The tablet came with Windows 10 by default. Does anyone have the experience with what distro and pdf reader to use?\n\nIt has to be lightweight due to 1.3Ghz Atom processor and 1Gb of Ram.", - "author_fullname": "t2_y0rlp", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Linux based Ebook reader tablet", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hnecim", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.56, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 2, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 2, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594231304.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.linux", - "allow_live_comments": False, - "selftext_html": '<!-- SC_OFF --><div class="md"><p>I would like to turn my old Asus tablet into an ultimate linux-based Ebook reader. It&#39;s currently running kali linux due to my netsec background and I can&#39;t say that it runs flawlessly. The tablet came with Windows 10 by default. Does anyone have the experience with what distro and pdf reader to use?</p>\n\n<p>It has to be lightweight due to 1.3Ghz Atom processor and 1Gb of Ram.</p>\n</div><!-- SC_ON -->', - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": True, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hnecim", - "is_robot_indexable": True, - "report_reasons": None, - "author": "Kikur", - "discussion_type": None, - "num_comments": 5, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hnecim/linux_based_ebook_reader_tablet/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://www.reddit.com/r/linux/comments/hnecim/linux_based_ebook_reader_tablet/", - "subreddit_subscribers": 543995, - "created_utc": 1594202504.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "", - "author_fullname": "t2_300vb", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Backing up my work-provided Windows laptop with Debian, ZFS and SquashFS", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hn2ro8", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.74, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 23, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 23, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594183686.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "thanassis.space", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://www.thanassis.space/backupCOVID.html", - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hn2ro8", - "is_robot_indexable": True, - "report_reasons": None, - "author": "ttsiodras", - "discussion_type": None, - "num_comments": 5, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hn2ro8/backing_up_my_workprovided_windows_laptop_with/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://www.thanassis.space/backupCOVID.html", - "subreddit_subscribers": 543995, - "created_utc": 1594154886.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "", - "author_fullname": "t2_2ccbdhht", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Debian influences everywhere", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": True, - "name": "t3_hnndj2", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.36, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 0, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": True, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 0, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "crosspost_parent_list": [ - { - "approved_at_utc": None, - "subreddit": "ramen", - "selftext": "", - "author_fullname": "t2_1e5jztuf", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "My 1st Attempt for Tori Paitan", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/ramen", - "hidden": False, - "pwls": 6, - "link_flair_css_class": "", - "downs": 0, - "top_awarded_type": None, - "hide_score": True, - "name": "t3_hnn89u", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 1.0, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 2, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": True, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "Homemade", - "can_mod_post": False, - "score": 2, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": False, - "mod_note": None, - "created": 1594263979.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "i.redd.it", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://i.redd.it/ai9r2wu5mo951.jpg", - "view_count": None, - "archived": False, - "no_follow": True, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "28b48e48-ce25-11e8-94f2-0e1ed223bf48", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qykd", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "#ffd635", - "id": "hnn89u", - "is_robot_indexable": True, - "report_reasons": None, - "author": "cheesychicken80", - "discussion_type": None, - "num_comments": 1, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/ramen/comments/hnn89u/my_1st_attempt_for_tori_paitan/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://i.redd.it/ai9r2wu5mo951.jpg", - "subreddit_subscribers": 257000, - "created_utc": 1594235179.0, - "num_crossposts": 1, - "media": None, - "is_video": False, - } - ], - "created": 1594264403.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "i.redd.it", - "allow_live_comments": False, - "selftext_html": None, - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "url_overridden_by_dest": "https://i.redd.it/ai9r2wu5mo951.jpg", - "view_count": None, - "archived": False, - "no_follow": True, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hnndj2", - "is_robot_indexable": True, - "report_reasons": None, - "author": "dracardOner", - "discussion_type": None, - "num_comments": 0, - "send_replies": False, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "crosspost_parent": "t3_hnn89u", - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hnndj2/debian_influences_everywhere/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://i.redd.it/ai9r2wu5mo951.jpg", - "subreddit_subscribers": 543995, - "created_utc": 1594235603.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "There is an open issue in Electron-Builder to add option to easily create flatpak repo. This results in many electron apps not officially/easily supporting flatpak, thus solving this would help flatpak adoption and make it easier for users to install their favourite apps. See the issue on github for more info [https://github.com/electron-userland/electron-builder/issues/512](https://github.com/electron-userland/electron-builder/issues/512)\n\nSince there are no technical obstacles that prevent completing this task, I made a small bounty on gitpay [https://gitpay.me/#/task/352](https://gitpay.me/#/task/352) to motivate developers, and if you care about this issue, consider chiming in too, spreading the word or even giving a try at implementing this :)", - "author_fullname": "t2_5hgjidqm", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Crowdsource Flatpak support in Electron-Builder", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hmytic", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.76, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 37, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 37, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594171301.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.linux", - "allow_live_comments": False, - "selftext_html": '<!-- SC_OFF --><div class="md"><p>There is an open issue in Electron-Builder to add option to easily create flatpak repo. This results in many electron apps not officially/easily supporting flatpak, thus solving this would help flatpak adoption and make it easier for users to install their favourite apps. See the issue on github for more info <a href="https://github.com/electron-userland/electron-builder/issues/512">https://github.com/electron-userland/electron-builder/issues/512</a></p>\n\n<p>Since there are no technical obstacles that prevent completing this task, I made a small bounty on gitpay <a href="https://gitpay.me/#/task/352">https://gitpay.me/#/task/352</a> to motivate developers, and if you care about this issue, consider chiming in too, spreading the word or even giving a try at implementing this :)</p>\n</div><!-- SC_ON -->', - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hmytic", - "is_robot_indexable": True, - "report_reasons": None, - "author": "ignapk", - "discussion_type": None, - "num_comments": 23, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hmytic/crowdsource_flatpak_support_in_electronbuilder/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://www.reddit.com/r/linux/comments/hmytic/crowdsource_flatpak_support_in_electronbuilder/", - "subreddit_subscribers": 543995, - "created_utc": 1594142501.0, - "num_crossposts": 5, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "I was experiencing graphic issues and glitches in some games while using Linux Ubuntu 20.04 LTS with my Ryzen 3 3250u CPU and I wanted to share how I fixed this issue for anyone else with this same problem.\n\nFirst thing you should try is setting 'AMD_DEBUG=nodmacopyimage' as an environmental variable. This only partly fixed the issue for me as most of the in-game textures were still glitchy and messed up. However this method seemed to work for some other people https://gitlab.freedesktop.org/mesa/mesa/-/issues/2814\n\nThe second method I tried was downgrading from Ubuntu 20.04 to Ubuntu 19.10. This fixed my problem instantly and the glitchy in-game textures were no longer an issue.\n\n\nIm still new to Linux and not very tech savvy so I can't provide a detailed explanation of what causes this problem and why these methods seem to fix it however I'm pretty sure its something to do with the AMD graphics drivers. Hopefully this issue will be fixed in the next Ubuntu update\n\nHope this helped ;)", - "author_fullname": "t2_6qntnayu", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Linux Graphical Glitches on Ryzen CPUs", - "link_flair_richtext": [{"e": "text", "t": "Tips and Tricks"}], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": "", - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hmxiyt", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.79, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 20, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": "Tips and Tricks", - "can_mod_post": False, - "score": 20, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594167246.0, - "link_flair_type": "richtext", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.linux", - "allow_live_comments": False, - "selftext_html": '<!-- SC_OFF --><div class="md"><p>I was experiencing graphic issues and glitches in some games while using Linux Ubuntu 20.04 LTS with my Ryzen 3 3250u CPU and I wanted to share how I fixed this issue for anyone else with this same problem.</p>\n\n<p>First thing you should try is setting &#39;AMD_DEBUG=nodmacopyimage&#39; as an environmental variable. This only partly fixed the issue for me as most of the in-game textures were still glitchy and messed up. However this method seemed to work for some other people <a href="https://gitlab.freedesktop.org/mesa/mesa/-/issues/2814">https://gitlab.freedesktop.org/mesa/mesa/-/issues/2814</a></p>\n\n<p>The second method I tried was downgrading from Ubuntu 20.04 to Ubuntu 19.10. This fixed my problem instantly and the glitchy in-game textures were no longer an issue.</p>\n\n<p>Im still new to Linux and not very tech savvy so I can&#39;t provide a detailed explanation of what causes this problem and why these methods seem to fix it however I&#39;m pretty sure its something to do with the AMD graphics drivers. Hopefully this issue will be fixed in the next Ubuntu update</p>\n\n<p>Hope this helped ;)</p>\n</div><!-- SC_ON -->', - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "link_flair_template_id": "de62f716-76df-11ea-802c-0e7469f68f6b", - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "#00a6a5", - "id": "hmxiyt", - "is_robot_indexable": True, - "report_reasons": None, - "author": "Inolicious_", - "discussion_type": None, - "num_comments": 9, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hmxiyt/linux_graphical_glitches_on_ryzen_cpus/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://www.reddit.com/r/linux/comments/hmxiyt/linux_graphical_glitches_on_ryzen_cpus/", - "subreddit_subscribers": 543995, - "created_utc": 1594138446.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - { - "kind": "t3", - "data": { - "approved_at_utc": None, - "subreddit": "linux", - "selftext": "Well, alright, at various points in my life I may have been more pleased, but Windows has been losing my support for years one small nitpick at a time. Just wanted to share the change for whoever cares.\n\n* I liked the look and massive size of the windows less and less \n* As a programmer using bash and zsh on cygwin became more and more annoying\n* Windows keeps randomly changing stuff that I never wanted, like my downloads folder becoming a date-sorted list instead of an actual folder (and switching it back when I changed it!)\n* Adding cortana and the like and making it difficult to disable\n* Windows update.\n* Almost every bit of software I have at this point is also on linux or through a browser!\n\nI switched to Manjaro-Gnome and never looked back.\n\n* It's sleeker/runs faster.\n* Uses less RAM\n* Uses rolling updates\n* I can finally just use a built-in terminal\n* Has an easier to understand file structure, despite its complexity.\n* Is surprisingly easy to use. The only difficult part really was finding the wifi driver, and that was actually because it was mislabeled by the manufacturer.\n* Gnome is definitely nicer to use than Windows 10.\n* Searching for files and programs works well! I really didn't need windows to fail to find a program I had installed and instead offer to search for it online... through Bing on Edge.\n\nI never knew how much bloat Windows had until I switched over. This is so damn nice. I don't know why I didn't consider Linux as a serious alternative until recently. Steam Proton has also come a long, long way, I haven't had issues with a game yet.\n\nAnyways, I just wanted to rant, and I'm probably going to install an Manjaro-xfce on a bunch of old laptops.", - "author_fullname": "t2_8zm4y", - "saved": False, - "mod_reason_title": None, - "gilded": 0, - "clicked": False, - "title": "Switched from Windows 10 to Manjaro, never been happier", - "link_flair_richtext": [], - "subreddit_name_prefixed": "r/linux", - "hidden": False, - "pwls": 6, - "link_flair_css_class": None, - "downs": 0, - "top_awarded_type": None, - "hide_score": False, - "name": "t3_hmgujt", - "quarantine": False, - "link_flair_text_color": "dark", - "upvote_ratio": 0.92, - "author_flair_background_color": None, - "subreddit_type": "public", - "ups": 598, - "total_awards_received": 0, - "media_embed": {}, - "author_flair_template_id": None, - "is_original_content": False, - "user_reports": [], - "secure_media": None, - "is_reddit_media_domain": False, - "is_meta": False, - "category": None, - "secure_media_embed": {}, - "link_flair_text": None, - "can_mod_post": False, - "score": 598, - "approved_by": None, - "author_premium": False, - "thumbnail": "", - "edited": False, - "author_flair_css_class": None, - "author_flair_richtext": [], - "gildings": {}, - "content_categories": None, - "is_self": True, - "mod_note": None, - "created": 1594099445.0, - "link_flair_type": "text", - "wls": 6, - "removed_by_category": None, - "banned_by": None, - "author_flair_type": "text", - "domain": "self.linux", - "allow_live_comments": False, - "selftext_html": '<!-- SC_OFF --><div class="md"><p>Well, alright, at various points in my life I may have been more pleased, but Windows has been losing my support for years one small nitpick at a time. Just wanted to share the change for whoever cares.</p>\n\n<ul>\n<li>I liked the look and massive size of the windows less and less </li>\n<li>As a programmer using bash and zsh on cygwin became more and more annoying</li>\n<li>Windows keeps randomly changing stuff that I never wanted, like my downloads folder becoming a date-sorted list instead of an actual folder (and switching it back when I changed it!)</li>\n<li>Adding cortana and the like and making it difficult to disable</li>\n<li>Windows update.</li>\n<li>Almost every bit of software I have at this point is also on linux or through a browser!</li>\n</ul>\n\n<p>I switched to Manjaro-Gnome and never looked back.</p>\n\n<ul>\n<li>It&#39;s sleeker/runs faster.</li>\n<li>Uses less RAM</li>\n<li>Uses rolling updates</li>\n<li>I can finally just use a built-in terminal</li>\n<li>Has an easier to understand file structure, despite its complexity.</li>\n<li>Is surprisingly easy to use. The only difficult part really was finding the wifi driver, and that was actually because it was mislabeled by the manufacturer.</li>\n<li>Gnome is definitely nicer to use than Windows 10.</li>\n<li>Searching for files and programs works well! I really didn&#39;t need windows to fail to find a program I had installed and instead offer to search for it online... through Bing on Edge.</li>\n</ul>\n\n<p>I never knew how much bloat Windows had until I switched over. This is so damn nice. I don&#39;t know why I didn&#39;t consider Linux as a serious alternative until recently. Steam Proton has also come a long, long way, I haven&#39;t had issues with a game yet.</p>\n\n<p>Anyways, I just wanted to rant, and I&#39;m probably going to install an Manjaro-xfce on a bunch of old laptops.</p>\n</div><!-- SC_ON -->', - "likes": None, - "suggested_sort": None, - "banned_at_utc": None, - "view_count": None, - "archived": False, - "no_follow": False, - "is_crosspostable": True, - "pinned": False, - "over_18": False, - "all_awardings": [], - "awarders": [], - "media_only": False, - "can_gild": True, - "spoiler": False, - "locked": False, - "author_flair_text": None, - "treatment_tags": [], - "visited": False, - "removed_by": None, - "num_reports": None, - "distinguished": None, - "subreddit_id": "t5_2qh1a", - "mod_reason_by": None, - "removal_reason": None, - "link_flair_background_color": "", - "id": "hmgujt", - "is_robot_indexable": True, - "report_reasons": None, - "author": "ForShotgun", - "discussion_type": None, - "num_comments": 213, - "send_replies": True, - "whitelist_status": "all_ads", - "contest_mode": False, - "mod_reports": [], - "author_patreon_flair": False, - "author_flair_text_color": None, - "permalink": "/r/linux/comments/hmgujt/switched_from_windows_10_to_manjaro_never_been/", - "parent_whitelist_status": "all_ads", - "stickied": False, - "url": "https://www.reddit.com/r/linux/comments/hmgujt/switched_from_windows_10_to_manjaro_never_been/", - "subreddit_subscribers": 543995, - "created_utc": 1594070645.0, - "num_crossposts": 0, - "media": None, - "is_video": False, - }, - }, - ], - "after": "t3_hmgujt", - "before": None, - }, -} diff --git a/src/newsreader/news/collection/tests/reddit/stream/tests.py b/src/newsreader/news/collection/tests/reddit/stream/tests.py deleted file mode 100644 index 19aff61..0000000 --- a/src/newsreader/news/collection/tests/reddit/stream/tests.py +++ /dev/null @@ -1,144 +0,0 @@ -from json.decoder import JSONDecodeError -from unittest.mock import patch -from uuid import uuid4 - -from django.test import TestCase - -from newsreader.accounts.tests.factories import UserFactory -from newsreader.news.collection.exceptions import ( - StreamDeniedException, - StreamException, - StreamForbiddenException, - StreamNotFoundException, - StreamParseException, - StreamTimeOutException, -) -from newsreader.news.collection.reddit import RedditStream -from newsreader.news.collection.tests.factories import SubredditFactory -from newsreader.news.collection.tests.reddit.stream.mocks import simple_mock - - -class RedditStreamTestCase(TestCase): - def setUp(self): - self.maxDiff = None - - self.patched_fetch = patch("newsreader.news.collection.reddit.fetch") - self.mocked_fetch = self.patched_fetch.start() - - def tearDown(self): - patch.stopall() - - def test_simple_stream(self): - self.mocked_fetch.return_value.json.return_value = simple_mock - - access_token = str(uuid4()) - user = UserFactory(reddit_access_token=access_token) - - subreddit = SubredditFactory(user=user) - stream = RedditStream(subreddit) - - data, stream = stream.read() - - self.assertEquals(data, simple_mock) - self.assertEquals(stream, stream) - self.mocked_fetch.assert_called_once_with( - subreddit.url, headers={"Authorization": f"bearer {access_token}"} - ) - - def test_stream_raises_exception(self): - self.mocked_fetch.side_effect = StreamException - - access_token = str(uuid4()) - user = UserFactory(reddit_access_token=access_token) - - subreddit = SubredditFactory(user=user) - stream = RedditStream(subreddit) - - with self.assertRaises(StreamException): - stream.read() - - self.mocked_fetch.assert_called_once_with( - subreddit.url, headers={"Authorization": f"bearer {access_token}"} - ) - - def test_stream_raises_denied_exception(self): - self.mocked_fetch.side_effect = StreamDeniedException - - access_token = str(uuid4()) - user = UserFactory(reddit_access_token=access_token) - - subreddit = SubredditFactory(user=user) - stream = RedditStream(subreddit) - - with self.assertRaises(StreamDeniedException): - stream.read() - - self.mocked_fetch.assert_called_once_with( - subreddit.url, headers={"Authorization": f"bearer {access_token}"} - ) - - def test_stream_raises_not_found_exception(self): - self.mocked_fetch.side_effect = StreamNotFoundException - - access_token = str(uuid4()) - user = UserFactory(reddit_access_token=access_token) - - subreddit = SubredditFactory(user=user) - stream = RedditStream(subreddit) - - with self.assertRaises(StreamNotFoundException): - stream.read() - - self.mocked_fetch.assert_called_once_with( - subreddit.url, headers={"Authorization": f"bearer {access_token}"} - ) - - def test_stream_raises_time_out_exception(self): - self.mocked_fetch.side_effect = StreamTimeOutException - - access_token = str(uuid4()) - user = UserFactory(reddit_access_token=access_token) - - subreddit = SubredditFactory(user=user) - stream = RedditStream(subreddit) - - with self.assertRaises(StreamTimeOutException): - stream.read() - - self.mocked_fetch.assert_called_once_with( - subreddit.url, headers={"Authorization": f"bearer {access_token}"} - ) - - def test_stream_raises_forbidden_exception(self): - self.mocked_fetch.side_effect = StreamForbiddenException - - access_token = str(uuid4()) - user = UserFactory(reddit_access_token=access_token) - - subreddit = SubredditFactory(user=user) - stream = RedditStream(subreddit) - - with self.assertRaises(StreamForbiddenException): - stream.read() - - self.mocked_fetch.assert_called_once_with( - subreddit.url, headers={"Authorization": f"bearer {access_token}"} - ) - - def test_stream_raises_parse_exception(self): - self.mocked_fetch.return_value.json.side_effect = JSONDecodeError( - "No json found", "{}", 5 - ) - - access_token = str(uuid4()) - user = UserFactory(reddit_access_token=access_token) - - subreddit = SubredditFactory(user=user) - stream = RedditStream(subreddit) - - with self.assertRaises(StreamParseException): - stream.read() - - self.mocked_fetch.assert_called_once_with( - subreddit.url, headers={"Authorization": f"bearer {access_token}"} - ) diff --git a/src/newsreader/news/collection/tests/reddit/test_scheduler.py b/src/newsreader/news/collection/tests/reddit/test_scheduler.py deleted file mode 100644 index 0f04d53..0000000 --- a/src/newsreader/news/collection/tests/reddit/test_scheduler.py +++ /dev/null @@ -1,142 +0,0 @@ -from datetime import timedelta - -from django.test import TestCase -from django.utils import timezone - -from freezegun import freeze_time - -from newsreader.accounts.tests.factories import UserFactory -from newsreader.news.collection.choices import RuleTypeChoices -from newsreader.news.collection.reddit import RedditScheduler -from newsreader.news.collection.tests.factories import CollectionRuleFactory - - -@freeze_time("2019-10-30 12:30:00") -class RedditSchedulerTestCase(TestCase): - def test_simple(self): - user_1 = UserFactory( - reddit_access_token="1231414", reddit_refresh_token="5235262" - ) - user_2 = UserFactory( - reddit_access_token="3414777", reddit_refresh_token="3423425" - ) - - user_1_rules = [ - CollectionRuleFactory( - user=user_1, - type=RuleTypeChoices.subreddit, - last_run=timezone.now() - timedelta(days=4), - enabled=True, - ), - CollectionRuleFactory( - user=user_1, - type=RuleTypeChoices.subreddit, - last_run=timezone.now() - timedelta(days=3), - enabled=True, - ), - CollectionRuleFactory( - user=user_1, - type=RuleTypeChoices.subreddit, - last_run=timezone.now() - timedelta(days=2), - enabled=True, - ), - ] - - user_2_rules = [ - CollectionRuleFactory( - user=user_2, - type=RuleTypeChoices.subreddit, - last_run=timezone.now() - timedelta(days=4), - enabled=True, - ), - CollectionRuleFactory( - user=user_2, - type=RuleTypeChoices.subreddit, - last_run=timezone.now() - timedelta(days=3), - enabled=True, - ), - CollectionRuleFactory( - user=user_2, - type=RuleTypeChoices.subreddit, - last_run=timezone.now() - timedelta(days=2), - enabled=True, - ), - ] - - scheduler = RedditScheduler() - scheduled_subreddits = scheduler.get_scheduled_rules() - - user_1_batch = [subreddit.pk for subreddit in scheduled_subreddits[0]] - - self.assertIn(user_1_rules[0].pk, user_1_batch) - self.assertIn(user_1_rules[1].pk, user_1_batch) - self.assertIn(user_1_rules[2].pk, user_1_batch) - - user_2_batch = [subreddit.pk for subreddit in scheduled_subreddits[1]] - - self.assertIn(user_2_rules[0].pk, user_2_batch) - self.assertIn(user_2_rules[1].pk, user_2_batch) - self.assertIn(user_2_rules[2].pk, user_2_batch) - - def test_max_amount(self): - users = UserFactory.create_batch( - reddit_access_token="1231414", reddit_refresh_token="5235262", size=5 - ) - - nested_rules = [ - CollectionRuleFactory.create_batch( - name=f"rule-{index}", - type=RuleTypeChoices.subreddit, - last_run=timezone.now() - timedelta(seconds=index), - enabled=True, - user=user, - size=15, - ) - for index, user in enumerate(users) - ] - - rules = [rule for rule_list in nested_rules for rule in rule_list] - - scheduler = RedditScheduler() - scheduled_subreddits = [ - subreddit.pk - for batch in scheduler.get_scheduled_rules() - for subreddit in batch - ] - - for rule in rules[16:76]: - with self.subTest(rule=rule): - self.assertIn(rule.pk, scheduled_subreddits) - - for rule in rules[0:15]: - with self.subTest(rule=rule): - self.assertNotIn(rule.pk, scheduled_subreddits) - - def test_max_user_amount(self): - user = UserFactory( - reddit_access_token="1231414", reddit_refresh_token="5235262" - ) - - rules = [ - CollectionRuleFactory( - name=f"rule-{index}", - type=RuleTypeChoices.subreddit, - last_run=timezone.now() - timedelta(seconds=index), - enabled=True, - user=user, - ) - for index in range(1, 17) - ] - - scheduler = RedditScheduler() - scheduled_subreddits = [ - subreddit.pk - for batch in scheduler.get_scheduled_rules() - for subreddit in batch - ] - - for rule in rules[1:16]: - with self.subTest(rule=rule): - self.assertIn(rule.pk, scheduled_subreddits) - - self.assertNotIn(rules[0].pk, scheduled_subreddits) diff --git a/src/newsreader/news/collection/tests/views/test_crud.py b/src/newsreader/news/collection/tests/views/test_crud.py index ceeb40b..d4bd731 100644 --- a/src/newsreader/news/collection/tests/views/test_crud.py +++ b/src/newsreader/news/collection/tests/views/test_crud.py @@ -88,17 +88,3 @@ class FeedUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): self.rule.refresh_from_db() self.assertEqual(self.rule.category, None) - - def test_rules_only(self): - rule = FeedFactory( - name="Python", - url="https://reddit.com/r/python", - user=self.user, - category=self.category, - type=RuleTypeChoices.subreddit, - ) - url = reverse("news:collection:feed-update", kwargs={"pk": rule.pk}) - - response = self.client.get(url) - - self.assertEqual(response.status_code, 404) diff --git a/src/newsreader/news/collection/tests/views/test_subreddit_views.py b/src/newsreader/news/collection/tests/views/test_subreddit_views.py deleted file mode 100644 index 4eac3b4..0000000 --- a/src/newsreader/news/collection/tests/views/test_subreddit_views.py +++ /dev/null @@ -1,133 +0,0 @@ -from django.test import TestCase -from django.urls import reverse -from django.utils.translation import gettext as _ - -from newsreader.news.collection.choices import RuleTypeChoices -from newsreader.news.collection.models import CollectionRule -from newsreader.news.collection.reddit import REDDIT_API_URL, REDDIT_URL -from newsreader.news.collection.tests.factories import SubredditFactory -from newsreader.news.collection.tests.views.base import CollectionRuleViewTestCase -from newsreader.news.core.tests.factories import CategoryFactory - - -class SubRedditCreateViewTestCase(CollectionRuleViewTestCase, TestCase): - def setUp(self): - super().setUp() - - self.form_data = { - "name": "new rule", - "url": f"{REDDIT_API_URL}/r/aww", - "category": str(self.category.pk), - "reddit_allow_nfsw": False, - "reddit_allow_spoiler": False, - "reddit_allow_viewed": True, - "reddit_upvotes_min": 0, - "reddit_comments_min": 0, - } - - self.url = reverse("news:collection:subreddit-create") - - def test_creation(self): - response = self.client.post(self.url, self.form_data) - - self.assertEqual(response.status_code, 302) - - rule = CollectionRule.objects.get(name="new rule") - - self.assertEqual(rule.type, RuleTypeChoices.subreddit) - self.assertEqual(rule.url, f"{REDDIT_API_URL}/r/aww") - self.assertEqual(rule.favicon, None) - self.assertEqual(rule.category.pk, self.category.pk) - self.assertEqual(rule.user.pk, self.user.pk) - - def test_regular_reddit_url(self): - self.form_data.update(url=f"{REDDIT_URL}/r/aww") - - response = self.client.post(self.url, self.form_data) - - self.assertContains(response, _("This does not look like an Reddit API URL")) - - -class SubRedditUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): - def setUp(self): - super().setUp() - - self.rule = SubredditFactory( - name="Python", - url=f"{REDDIT_API_URL}/r/python.json", - user=self.user, - category=self.category, - type=RuleTypeChoices.subreddit, - ) - self.url = reverse( - "news:collection:subreddit-update", kwargs={"pk": self.rule.pk} - ) - - self.form_data = { - "name": self.rule.name, - "url": self.rule.url, - "category": str(self.category.pk), - "reddit_allow_nfsw": False, - "reddit_allow_spoiler": False, - "reddit_allow_viewed": True, - "reddit_upvotes_min": 0, - "reddit_comments_min": 0, - } - - def test_name_change(self): - self.form_data.update(name="Python 2") - - response = self.client.post(self.url, self.form_data) - self.assertEqual(response.status_code, 302) - - self.rule.refresh_from_db() - - self.assertEqual(self.rule.name, "Python 2") - - def test_category_change(self): - new_category = CategoryFactory(user=self.user) - - self.form_data.update(category=new_category.pk) - - response = self.client.post(self.url, self.form_data) - self.assertEqual(response.status_code, 302) - - self.rule.refresh_from_db() - - self.assertEqual(self.rule.category.pk, new_category.pk) - - def test_subreddit_rules_only(self): - rule = SubredditFactory( - name="Fake subreddit", - url="https://leddit.com/r/python", - user=self.user, - category=self.category, - type=RuleTypeChoices.feed, - ) - url = reverse("news:collection:subreddit-update", kwargs={"pk": rule.pk}) - - response = self.client.get(url) - - self.assertEqual(response.status_code, 404) - - def test_url_change(self): - self.form_data.update(name="aww", url=f"{REDDIT_API_URL}/r/aww") - - response = self.client.post(self.url, self.form_data) - - self.assertEqual(response.status_code, 302) - - rule = CollectionRule.objects.get(name="aww") - - self.assertEqual(rule.type, RuleTypeChoices.subreddit) - self.assertEqual(rule.url, f"{REDDIT_API_URL}/r/aww") - self.assertEqual(rule.favicon, None) - self.assertEqual(rule.category.pk, self.category.pk) - self.assertEqual(rule.user.pk, self.user.pk) - - def test_regular_reddit_url(self): - self.form_data.update(url=f"{REDDIT_URL}/r/aww") - - response = self.client.post(self.url, self.form_data) - - self.assertContains(response, _("This does not look like an Reddit API URL")) diff --git a/src/newsreader/news/collection/urls.py b/src/newsreader/news/collection/urls.py index e482002..a57a00e 100644 --- a/src/newsreader/news/collection/urls.py +++ b/src/newsreader/news/collection/urls.py @@ -14,8 +14,6 @@ from newsreader.news.collection.views import ( FeedCreateView, FeedUpdateView, OPMLImportView, - SubRedditCreateView, - SubRedditUpdateView, ) @@ -49,15 +47,4 @@ urlpatterns = [ name="rules-disable", ), path("rules/import/", login_required(OPMLImportView.as_view()), name="import"), - # Reddit - path( - "subreddits/create/", - login_required(SubRedditCreateView.as_view()), - name="subreddit-create", - ), - path( - "subreddits//", - login_required(SubRedditUpdateView.as_view()), - name="subreddit-update", - ), ] diff --git a/src/newsreader/news/collection/views/__init__.py b/src/newsreader/news/collection/views/__init__.py index 95d7b32..f4009db 100644 --- a/src/newsreader/news/collection/views/__init__.py +++ b/src/newsreader/news/collection/views/__init__.py @@ -3,10 +3,7 @@ from newsreader.news.collection.views.feed import ( FeedUpdateView, OPMLImportView, ) -from newsreader.news.collection.views.reddit import ( - SubRedditCreateView, - SubRedditUpdateView, -) + from newsreader.news.collection.views.rules import ( CollectionRuleBulkDeleteView, CollectionRuleBulkDisableView, @@ -19,8 +16,6 @@ __all__ = [ "FeedCreateView", "FeedUpdateView", "OPMLImportView", - "SubRedditCreateView", - "SubRedditUpdateView", "CollectionRuleBulkDeleteView", "CollectionRuleBulkDisableView", "CollectionRuleBulkEnableView", diff --git a/src/newsreader/news/collection/views/reddit.py b/src/newsreader/news/collection/views/reddit.py deleted file mode 100644 index 4e44e3f..0000000 --- a/src/newsreader/news/collection/views/reddit.py +++ /dev/null @@ -1,27 +0,0 @@ -from django.views.generic.edit import CreateView, UpdateView - -from newsreader.news.collection.choices import RuleTypeChoices -from newsreader.news.collection.forms import SubRedditForm -from newsreader.news.collection.views.base import ( - CollectionRuleDetailMixin, - CollectionRuleViewMixin, -) - - -class SubRedditCreateView( - CollectionRuleViewMixin, CollectionRuleDetailMixin, CreateView -): - form_class = SubRedditForm - template_name = "news/collection/views/subreddit-create.html" - - -class SubRedditUpdateView( - CollectionRuleViewMixin, CollectionRuleDetailMixin, UpdateView -): - form_class = SubRedditForm - template_name = "news/collection/views/subreddit-update.html" - context_object_name = "subreddit" - - def get_queryset(self): - queryset = super().get_queryset() - return queryset.filter(type=RuleTypeChoices.subreddit) diff --git a/src/newsreader/news/core/tests/factories.py b/src/newsreader/news/core/tests/factories.py index d3b62f0..88ce45e 100644 --- a/src/newsreader/news/core/tests/factories.py +++ b/src/newsreader/news/core/tests/factories.py @@ -4,7 +4,6 @@ import factory import factory.fuzzy from newsreader.accounts.tests.factories import UserFactory -from newsreader.news.collection.reddit import REDDIT_API_URL from newsreader.news.core.models import Category, Post @@ -36,10 +35,3 @@ class PostFactory(factory.django.DjangoModelFactory): class FeedPostFactory(PostFactory): rule = factory.SubFactory("newsreader.news.collection.tests.factories.FeedFactory") - - -class RedditPostFactory(PostFactory): - url = factory.fuzzy.FuzzyText(length=10, prefix=f"{REDDIT_API_URL}/") - rule = factory.SubFactory( - "newsreader.news.collection.tests.factories.SubredditFactory" - ) diff --git a/src/newsreader/news/core/views.py b/src/newsreader/news/core/views.py index d05603a..e7fc237 100644 --- a/src/newsreader/news/core/views.py +++ b/src/newsreader/news/core/views.py @@ -20,9 +20,6 @@ class NewsView(NavListMixin, TemplateView): **context, "homepageSettings": { "feedUrl": reverse_lazy("news:collection:feed-update", args=(0,)), - "subredditUrl": reverse_lazy( - "news:collection:subreddit-update", args=(0,) - ), "categoriesUrl": reverse_lazy("news:core:category-update", args=(0,)), "timezone": settings.TIME_ZONE, "autoMarking": self.request.user.auto_mark_read, diff --git a/src/newsreader/scss/components/index.scss b/src/newsreader/scss/components/index.scss index dba0131..c8a933a 100644 --- a/src/newsreader/scss/components/index.scss +++ b/src/newsreader/scss/components/index.scss @@ -18,8 +18,6 @@ @import './sidebar/index'; @import './table/index'; -@import './integrations/index'; - @import './rules/index'; @import './post/index'; diff --git a/src/newsreader/scss/components/integrations/_integrations.scss b/src/newsreader/scss/components/integrations/_integrations.scss deleted file mode 100644 index 3fbb593..0000000 --- a/src/newsreader/scss/components/integrations/_integrations.scss +++ /dev/null @@ -1,13 +0,0 @@ -.integrations { - display: flex; - flex-direction: column; - gap: 15px; - - padding: 15px; - - &__controls { - display: flex; - flex-wrap: wrap; - gap: 10px; - } -} diff --git a/src/newsreader/scss/components/integrations/index.scss b/src/newsreader/scss/components/integrations/index.scss deleted file mode 100644 index 7f9e759..0000000 --- a/src/newsreader/scss/components/integrations/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import './integrations'; diff --git a/src/newsreader/scss/elements/button/_button.scss b/src/newsreader/scss/elements/button/_button.scss index 96ee2c8..237e37d 100644 --- a/src/newsreader/scss/elements/button/_button.scss +++ b/src/newsreader/scss/elements/button/_button.scss @@ -13,12 +13,14 @@ cursor: pointer; } - &--success, &--confirm { + &--success, + &--confirm { background-color: var(--confirm-color); color: $white !important; } - &--error, &--cancel { + &--error, + &--cancel { color: $white !important; background-color: var(--danger-color); } @@ -28,15 +30,6 @@ background-color: var(--info-color); } - &--reddit { - color: $white !important; - background-color: $reddit-orange; - - &:hover { - background-color: lighten($reddit-orange, 5%); - } - } - &--disabled { color: var(--font-color) !important; background-color: var(--background-color-secondary) !important; diff --git a/src/newsreader/scss/pages/index.scss b/src/newsreader/scss/pages/index.scss index 2ac0bb2..44ca8a7 100644 --- a/src/newsreader/scss/pages/index.scss +++ b/src/newsreader/scss/pages/index.scss @@ -12,4 +12,3 @@ @import './rules/index'; @import './settings/index'; -@import './integrations/index'; diff --git a/src/newsreader/scss/pages/integrations/index.scss b/src/newsreader/scss/pages/integrations/index.scss deleted file mode 100644 index ccf52c3..0000000 --- a/src/newsreader/scss/pages/integrations/index.scss +++ /dev/null @@ -1,5 +0,0 @@ -#integrations--page { - .section { - width: 70%; - } -} diff --git a/src/newsreader/scss/partials/_colors.scss b/src/newsreader/scss/partials/_colors.scss index 1807a85..d2433f6 100644 --- a/src/newsreader/scss/partials/_colors.scss +++ b/src/newsreader/scss/partials/_colors.scss @@ -59,6 +59,3 @@ $dark-info-color: $blue; $dark-info-font-color: $white; $dark-sidebar-background-color: $dark-background-color-secondary; - -// Third party -$reddit-orange: rgba(255, 69, 0, 1); From bfd081337b287bcdf567eba7252e1bbab4e0595e Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Fri, 28 Mar 2025 21:41:47 +0100 Subject: [PATCH 404/422] Run formatting / fix lint errors --- ...emove_user_reddit_access_token_and_more.py | 11 +++--- ...llectionrule_reddit_allow_nfsw_and_more.py | 39 ++++++++++--------- src/newsreader/news/collection/tasks.py | 1 - .../tests/favicon/collector/mocks.py | 6 +-- .../collection/tests/feed/client/mocks.py | 2 +- .../collection/tests/feed/collector/mocks.py | 18 ++++----- .../collection/tests/feed/stream/mocks.py | 10 ++--- src/newsreader/news/collection/utils.py | 2 +- .../news/collection/views/__init__.py | 1 - 9 files changed, 44 insertions(+), 46 deletions(-) diff --git a/src/newsreader/accounts/migrations/0018_remove_user_reddit_access_token_and_more.py b/src/newsreader/accounts/migrations/0018_remove_user_reddit_access_token_and_more.py index 19bda0c..cf8816b 100644 --- a/src/newsreader/accounts/migrations/0018_remove_user_reddit_access_token_and_more.py +++ b/src/newsreader/accounts/migrations/0018_remove_user_reddit_access_token_and_more.py @@ -4,18 +4,17 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('accounts', '0017_auto_20240906_0914'), + ("accounts", "0017_auto_20240906_0914"), ] operations = [ migrations.RemoveField( - model_name='user', - name='reddit_access_token', + model_name="user", + name="reddit_access_token", ), migrations.RemoveField( - model_name='user', - name='reddit_refresh_token', + model_name="user", + name="reddit_refresh_token", ), ] diff --git a/src/newsreader/news/collection/migrations/0018_remove_collectionrule_reddit_allow_nfsw_and_more.py b/src/newsreader/news/collection/migrations/0018_remove_collectionrule_reddit_allow_nfsw_and_more.py index 39bdb8b..cc61aee 100644 --- a/src/newsreader/news/collection/migrations/0018_remove_collectionrule_reddit_allow_nfsw_and_more.py +++ b/src/newsreader/news/collection/migrations/0018_remove_collectionrule_reddit_allow_nfsw_and_more.py @@ -4,43 +4,44 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('collection', '0017_remove_collectionrule_timezone'), + ("collection", "0017_remove_collectionrule_timezone"), ] operations = [ migrations.RemoveField( - model_name='collectionrule', - name='reddit_allow_nfsw', + model_name="collectionrule", + name="reddit_allow_nfsw", ), migrations.RemoveField( - model_name='collectionrule', - name='reddit_allow_spoiler', + model_name="collectionrule", + name="reddit_allow_spoiler", ), migrations.RemoveField( - model_name='collectionrule', - name='reddit_allow_viewed', + model_name="collectionrule", + name="reddit_allow_viewed", ), migrations.RemoveField( - model_name='collectionrule', - name='reddit_comments_min', + model_name="collectionrule", + name="reddit_comments_min", ), migrations.RemoveField( - model_name='collectionrule', - name='reddit_downvotes_max', + model_name="collectionrule", + name="reddit_downvotes_max", ), migrations.RemoveField( - model_name='collectionrule', - name='reddit_upvotes_min', + model_name="collectionrule", + name="reddit_upvotes_min", ), migrations.RemoveField( - model_name='collectionrule', - name='screen_name', + model_name="collectionrule", + name="screen_name", ), migrations.AlterField( - model_name='collectionrule', - name='type', - field=models.CharField(choices=[('feed', 'Feed')], default='feed', max_length=20), + model_name="collectionrule", + name="type", + field=models.CharField( + choices=[("feed", "Feed")], default="feed", max_length=20 + ), ), ] diff --git a/src/newsreader/news/collection/tasks.py b/src/newsreader/news/collection/tasks.py index f397244..b61936a 100644 --- a/src/newsreader/news/collection/tasks.py +++ b/src/newsreader/news/collection/tasks.py @@ -1,5 +1,4 @@ from django.core.exceptions import ObjectDoesNotExist -from django.utils.translation import gettext as _ from celery.exceptions import Reject from celery.utils.log import get_task_logger diff --git a/src/newsreader/news/collection/tests/favicon/collector/mocks.py b/src/newsreader/news/collection/tests/favicon/collector/mocks.py index ca06c2f..d9b65f1 100644 --- a/src/newsreader/news/collection/tests/favicon/collector/mocks.py +++ b/src/newsreader/news/collection/tests/favicon/collector/mocks.py @@ -44,7 +44,7 @@ feed_mock = { "base": "http://feeds.bbci.co.uk/news/rss.xml", "language": None, "type": "text/plain", - "value": "Trump's genocidal taunts will not " "end Iran - Zarif", + "value": "Trump's genocidal taunts will not end Iran - Zarif", }, }, { @@ -83,7 +83,7 @@ feed_mock = { "base": "http://feeds.bbci.co.uk/news/rss.xml", "language": None, "type": "text/plain", - "value": "Huawei's Android loss: How it " "affects you", + "value": "Huawei's Android loss: How it affects you", }, }, { @@ -124,7 +124,7 @@ feed_mock = { "base": "http://feeds.bbci.co.uk/news/rss.xml", "language": None, "type": "text/plain", - "value": "Birmingham head teacher threatened " "over LGBT lessons", + "value": "Birmingham head teacher threatened over LGBT lessons", }, }, ], diff --git a/src/newsreader/news/collection/tests/feed/client/mocks.py b/src/newsreader/news/collection/tests/feed/client/mocks.py index 25742fe..185f03d 100644 --- a/src/newsreader/news/collection/tests/feed/client/mocks.py +++ b/src/newsreader/news/collection/tests/feed/client/mocks.py @@ -42,7 +42,7 @@ simple_mock = { "base": "http://feeds.bbci.co.uk/news/rss.xml", "language": None, "type": "text/plain", - "value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif", + "value": "Trump's 'genocidal taunts' will not end Iran - Zarif", }, } ], diff --git a/src/newsreader/news/collection/tests/feed/collector/mocks.py b/src/newsreader/news/collection/tests/feed/collector/mocks.py index 96fab4b..7999f99 100644 --- a/src/newsreader/news/collection/tests/feed/collector/mocks.py +++ b/src/newsreader/news/collection/tests/feed/collector/mocks.py @@ -42,7 +42,7 @@ multiple_mock = { "base": "http://feeds.bbci.co.uk/news/rss.xml", "language": None, "type": "text/plain", - "value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif", + "value": "Trump's 'genocidal taunts' will not end Iran - Zarif", }, }, { @@ -81,7 +81,7 @@ multiple_mock = { "base": "http://feeds.bbci.co.uk/news/rss.xml", "language": None, "type": "text/plain", - "value": "Huawei's Android loss: How it " "affects you", + "value": "Huawei's Android loss: How it affects you", }, }, { @@ -122,7 +122,7 @@ multiple_mock = { "base": "http://feeds.bbci.co.uk/news/rss.xml", "language": None, "type": "text/plain", - "value": "Birmingham head teacher threatened " "over LGBT lessons", + "value": "Birmingham head teacher threatened over LGBT lessons", }, }, ], @@ -212,7 +212,7 @@ duplicate_mock = { "base": "http://feeds.bbci.co.uk/news/rss.xml", "language": None, "type": "text/plain", - "value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif", + "value": "Trump's 'genocidal taunts' will not end Iran - Zarif", }, }, { @@ -250,7 +250,7 @@ duplicate_mock = { "base": "http://feeds.bbci.co.uk/news/rss.xml", "language": None, "type": "text/plain", - "value": "Huawei's Android loss: How it " "affects you", + "value": "Huawei's Android loss: How it affects you", }, }, { @@ -290,7 +290,7 @@ duplicate_mock = { "base": "http://feeds.bbci.co.uk/news/rss.xml", "language": None, "type": "text/plain", - "value": "Birmingham head teacher threatened " "over LGBT lessons", + "value": "Birmingham head teacher threatened over LGBT lessons", }, }, ], @@ -356,7 +356,7 @@ multiple_update_mock = { "base": "http://feeds.bbci.co.uk/news/rss.xml", "language": None, "type": "text/plain", - "value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif", + "value": "Trump's 'genocidal taunts' will not end Iran - Zarif", }, }, { @@ -395,7 +395,7 @@ multiple_update_mock = { "base": "http://feeds.bbci.co.uk/news/rss.xml", "language": None, "type": "text/plain", - "value": "Huawei's Android loss: How it " "affects you", + "value": "Huawei's Android loss: How it affects you", }, }, { @@ -436,7 +436,7 @@ multiple_update_mock = { "base": "http://feeds.bbci.co.uk/news/rss.xml", "language": None, "type": "text/plain", - "value": "Birmingham head teacher threatened " "over LGBT lessons", + "value": "Birmingham head teacher threatened over LGBT lessons", }, }, ], diff --git a/src/newsreader/news/collection/tests/feed/stream/mocks.py b/src/newsreader/news/collection/tests/feed/stream/mocks.py index 7084641..e8d6856 100644 --- a/src/newsreader/news/collection/tests/feed/stream/mocks.py +++ b/src/newsreader/news/collection/tests/feed/stream/mocks.py @@ -73,12 +73,12 @@ simple_mock_parsed = { "not think face coverings should be " "mandatory in shops in England.", }, - "title": "Coronavirus: I trust people's sense on face masks - " "Gove", + "title": "Coronavirus: I trust people's sense on face masks - Gove", "title_detail": { "base": "", "language": None, "type": "text/plain", - "value": "Coronavirus: I trust people's sense " "on face masks - Gove", + "value": "Coronavirus: I trust people's sense on face masks - Gove", }, }, { @@ -109,7 +109,7 @@ simple_mock_parsed = { "base": "", "language": None, "type": "text/plain", - "value": "Farm outbreak leads 200 to self " "isolate", + "value": "Farm outbreak leads 200 to self isolate", }, }, { @@ -137,12 +137,12 @@ simple_mock_parsed = { "talks on tackling people " "smuggling.", }, - "title": "English Channel search operation after migrant " "crossings", + "title": "English Channel search operation after migrant crossings", "title_detail": { "base": "", "language": None, "type": "text/plain", - "value": "English Channel search operation " "after migrant crossings", + "value": "English Channel search operation after migrant crossings", }, }, ], diff --git a/src/newsreader/news/collection/utils.py b/src/newsreader/news/collection/utils.py index 827d446..36a3b9e 100644 --- a/src/newsreader/news/collection/utils.py +++ b/src/newsreader/news/collection/utils.py @@ -60,6 +60,6 @@ def truncate_text(cls, field_name, value): return value if len(value) > max_length: - return f"{value[:max_length - 1]}…" + return f"{value[: max_length - 1]}…" return value diff --git a/src/newsreader/news/collection/views/__init__.py b/src/newsreader/news/collection/views/__init__.py index f4009db..dc92557 100644 --- a/src/newsreader/news/collection/views/__init__.py +++ b/src/newsreader/news/collection/views/__init__.py @@ -3,7 +3,6 @@ from newsreader.news.collection.views.feed import ( FeedUpdateView, OPMLImportView, ) - from newsreader.news.collection.views.rules import ( CollectionRuleBulkDeleteView, CollectionRuleBulkDisableView, From 1417c5200724996777e55721ae3a2aa7ed3f9e65 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Fri, 28 Mar 2025 21:55:35 +0100 Subject: [PATCH 405/422] Apply prettier formatting --- package-lock.json | 4 ++-- src/newsreader/js/components/Messages.js | 4 ++-- src/newsreader/js/components/Selector.js | 4 ++-- src/newsreader/js/pages/categories/App.js | 10 +++++----- .../js/pages/homepage/components/PostModal.js | 6 +++--- .../js/pages/homepage/components/ScrollTop.js | 2 +- .../js/pages/homepage/components/postlist/PostList.js | 4 ++-- .../js/pages/homepage/components/sidebar/ReadButton.js | 2 +- 8 files changed, 18 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 82a511b..59e4d2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "newsreader", - "version": "0.4.4", + "version": "0.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "newsreader", - "version": "0.4.4", + "version": "0.5.3", "license": "GPL-3.0-or-later", "dependencies": { "@fortawesome/fontawesome-free": "^5.15.2", diff --git a/src/newsreader/js/components/Messages.js b/src/newsreader/js/components/Messages.js index e3d776e..dd3b2f8 100644 --- a/src/newsreader/js/components/Messages.js +++ b/src/newsreader/js/components/Messages.js @@ -3,13 +3,13 @@ import React from 'react'; class Messages extends React.Component { state = { messages: this.props.messages }; - close = (index) => { + close = index => { const newMessages = this.state.messages.filter((message, currentIndex) => { return currentIndex != index; }); this.setState({ messages: newMessages }); - } + }; render() { const messages = this.state.messages.map((message, index) => { diff --git a/src/newsreader/js/components/Selector.js b/src/newsreader/js/components/Selector.js index c6b117a..8933a59 100644 --- a/src/newsreader/js/components/Selector.js +++ b/src/newsreader/js/components/Selector.js @@ -9,13 +9,13 @@ class Selector { selectAllInput.onchange = this.onClick; } - onClick = (e) => { + onClick = e => { const targetValue = e.target.checked; this.inputs.forEach(input => { input.checked = targetValue; }); - } + }; } export default Selector; diff --git a/src/newsreader/js/pages/categories/App.js b/src/newsreader/js/pages/categories/App.js index ac237c3..db81a73 100644 --- a/src/newsreader/js/pages/categories/App.js +++ b/src/newsreader/js/pages/categories/App.js @@ -20,15 +20,15 @@ class App extends React.Component { }; } - selectCategory = (categoryId) => { + selectCategory = categoryId => { this.setState({ selectedCategoryId: categoryId }); - } + }; deselectCategory = () => { this.setState({ selectedCategoryId: null }); - } + }; - deleteCategory = (categoryId) => { + deleteCategory = categoryId => { const url = `/api/categories/${categoryId}/`; const options = { method: 'DELETE', @@ -56,7 +56,7 @@ class App extends React.Component { text: 'Unable to remove category, try again later', }; return this.setState({ selectedCategoryId: null, message: message }); - } + }; render() { const { categories } = this.state; diff --git a/src/newsreader/js/pages/homepage/components/PostModal.js b/src/newsreader/js/pages/homepage/components/PostModal.js index 5dacdf8..e319e10 100644 --- a/src/newsreader/js/pages/homepage/components/PostModal.js +++ b/src/newsreader/js/pages/homepage/components/PostModal.js @@ -31,13 +31,13 @@ class PostModal extends React.Component { window.removeEventListener('click', this.modalListener); } - modalListener = (e) => { + modalListener = e => { const targetClassName = e.target.className; if (this.props.post && targetClassName == 'modal post-modal') { this.props.unSelectPost(); } - } + }; render() { const post = this.props.post; @@ -66,7 +66,7 @@ class PostModal extends React.Component {

      - {% include "components/form/checkbox.html" with id="select-all" data_input="rules" - id_for_label="select-all" %} + {% include "components/form/checkbox.html" with id="select-all" data_input="rules" id_for_label="select-all" %} + + {% trans "Name" %} + + {% trans "Category" %} + + {% trans "URL" %} + + {% trans "Successfuly ran" %} + + {% trans "Enabled" %} {% trans "Name" %}{% trans "Category" %}{% trans "URL" %}{% trans "Successfuly ran" - %}{% trans "Enabled" %}